From a4eb5a580f9ba3560a96088bf52a4c55feda4db1 Mon Sep 17 00:00:00 2001 From: janghw Date: Wed, 5 Aug 2026 15:54:25 +0900 Subject: [PATCH] Initial commit --- .gitattributes | 24 + .gitignore | 41 ++ Dockerfile | 9 + README.md | 164 +++++++ build.gradle | 112 +++++ .../local-core-tools-manifest-sample-v1.json | 29 ++ ...-information-tools-manifest-sample-v1.json | 24 + ...ocal-process-tools-manifest-sample-v1.json | 24 + deploy/README.md | 132 ++++++ deploy/helm/mcp-server/Chart.yaml | 9 + deploy/helm/mcp-server/templates/_helpers.tpl | 62 +++ .../helm/mcp-server/templates/configmap.yaml | 37 ++ .../helm/mcp-server/templates/deployment.yaml | 88 ++++ .../mcp-server/templates/networkpolicy.yaml | 42 ++ .../templates/poddisruptionbudget.yaml | 24 + deploy/helm/mcp-server/templates/route.yaml | 24 + deploy/helm/mcp-server/templates/service.yaml | 13 + deploy/helm/mcp-server/values-dev.yaml | 27 ++ deploy/helm/mcp-server/values-prod.yaml | 41 ++ deploy/helm/mcp-server/values-test.yaml | 26 ++ deploy/helm/mcp-server/values.yaml | 157 +++++++ docs/architecture.md | 216 +++++++++ docs/codex-workflow.md | 60 +++ docs/contracts/agent-builder-mcp/README.md | 16 + .../agentbuilder-v0.2/initialize-request.json | 13 + .../initialize-response.json | 9 + .../initialized-notification.json | 5 + .../tools-call-execution-error-response.json | 13 + .../agentbuilder-v0.2/tools-call-request.json | 11 + .../tools-call-success-response.json | 16 + .../initialize-response.json | 17 + .../tools-call-execution-error-response.json | 13 + .../tools-call-invalid-params-response.json | 8 + .../agentbuilder-v0.3/tools-call-request.json | 12 + .../tools-call-success-response.json | 16 + .../tools-list-response.json | 67 +++ ...-search-exa-165316-initialize-request.json | 14 + ...search-exa-165316-initialize-response.json | 34 ++ ...h-exa-165316-initialized-notification.json | 5 + ...-search-exa-165316-tools-call-request.json | 13 + ...search-exa-165316-tools-call-response.json | 16 + .../01-exa-web-search-exa-165316.md | 183 ++++++++ ...-search-exa-165324-initialize-request.json | 14 + ...search-exa-165324-initialize-response.json | 34 ++ ...h-exa-165324-initialized-notification.json | 5 + ...-search-exa-165324-tools-call-request.json | 13 + ...search-exa-165324-tools-call-response.json | 16 + .../02-exa-web-search-exa-165324.md | 176 +++++++ ...-search-exa-165331-initialize-request.json | 14 + ...search-exa-165331-initialize-response.json | 34 ++ ...h-exa-165331-initialized-notification.json | 5 + ...-search-exa-165331-tools-call-request.json | 13 + ...search-exa-165331-tools-call-response.json | 16 + .../03-exa-web-search-exa-165331.md | 178 ++++++++ ...api-youtube-search-initialize-request.json | 14 + ...pi-youtube-search-initialize-response.json | 27 ++ ...api-youtube-search-tools-call-request.json | 13 + ...tools-call-response-visible-transcript.txt | 12 + .../2026-07-13/04-searchapi-youtube-search.md | 174 +++++++ ...t-list-collections-initialize-request.json | 14 + ...-list-collections-initialize-response.json | 30 ++ ...-collections-initialized-notification.json | 5 + ...t-list-collections-tools-call-request.json | 10 + ...-list-collections-tools-call-response.json | 40 ++ .../05-langconnect-list-collections.md | 140 ++++++ .../observed-samples/2026-07-13/README.md | 30 ++ .../protocol-v0.2-agentbuilder.md | 44 ++ .../protocol-v0.3-streaming-policy.md | 82 ++++ .../protocol-v1-agreement-baseline.md | 56 +++ docs/contracts/tool-service-mcp/README.md | 39 ++ .../TEMP-tool-list-loading-guide.md | 227 ++++++++++ .../bundle-v0.2/bundle-status-response.json | 54 +++ .../bundle-v0.2/manifest-response.json | 94 ++++ .../bundle-v0.2/mcp-bundle-config.yaml | 49 ++ .../protocol-v0.2-bundle-discovery.md | 349 ++++++++++++++ .../ADR-0001-stateless-execution-boundary.md | 23 + .../ADR-0002-tool-exposure-and-single-call.md | 32 ++ docs/decisions/ADR-0003-builder-tool-uid.md | 29 ++ .../ADR-0004-execution-guardrails.md | 30 ++ docs/decisions/ADR-0005-standard-tool-name.md | 25 + .../ADR-0006-no-authentication-in-mcp.md | 77 ++++ .../ADR-0007-one-mcp-per-tool-service.md | 96 ++++ .../ADR-0008-shared-host-path-routing.md | 53 +++ ...-0009-container-handles-public-mcp-path.md | 36 ++ docs/decisions/README.md | 23 + ...20260710-001-agentbuilder-mcp-interface.md | 50 ++ docs/discussions/README.md | 14 + docs/extension-points.md | 92 ++++ docs/mcp-java-sdk-adoption.md | 144 ++++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 8 + gradlew | 251 ++++++++++ gradlew.bat | 94 ++++ settings.gradle | 1 + .../dap/biz/mcp/McpServerApplication.java | 38 ++ .../dap/biz/mcp/config/HttpClientConfig.java | 45 ++ .../dap/biz/mcp/config/McpProperties.java | 173 +++++++ .../biz/mcp/context/McpRequestContext.java | 42 ++ .../mcp/context/McpRequestContextHolder.java | 47 ++ .../mcp/execute/ToolArgumentValidator.java | 121 +++++ .../dap/biz/mcp/execute/ToolCall.java | 10 + .../biz/mcp/execute/ToolExecutionService.java | 108 +++++ .../biz/mcp/execute/ToolRoutingService.java | 68 +++ .../dap/biz/mcp/jsonrpc/JsonRpcErrorCode.java | 46 ++ .../dap/biz/mcp/jsonrpc/JsonRpcException.java | 60 +++ .../dap/biz/mcp/jsonrpc/JsonRpcRequest.java | 17 + .../biz/mcp/jsonrpc/JsonRpcRequestParser.java | 65 +++ .../dap/biz/mcp/jsonrpc/JsonRpcResponse.java | 57 +++ .../dap/biz/mcp/method/InitializeHandler.java | 51 +++ .../InitializedNotificationHandler.java | 34 ++ .../mcp/method/McpMethodHandlerRegistry.java | 65 +++ .../dap/biz/mcp/method/ToolsCallHandler.java | 120 +++++ .../dap/biz/mcp/method/ToolsListHandler.java | 89 ++++ .../ToolBundleStatusEndpoint.java | 41 ++ .../ToolCatalogHealthIndicator.java | 43 ++ .../biz/mcp/observability/TraceLogger.java | 95 ++++ .../registry/LocalFileToolRegistryClient.java | 135 ++++++ .../mcp/registry/RedisToolRegistryCache.java | 92 ++++ .../biz/mcp/registry/ToolBundleDiscovery.java | 428 ++++++++++++++++++ .../registry/ToolBundleRegistryClient.java | 87 ++++ .../dap/biz/mcp/registry/ToolMetadata.java | 27 ++ .../biz/mcp/registry/ToolRegistryClient.java | 20 + .../ToolRegistryRefreshScheduler.java | 89 ++++ .../biz/mcp/registry/ToolRegistryService.java | 170 +++++++ .../biz/mcp/toolclient/HttpToolClient.java | 186 ++++++++ .../dap/biz/mcp/toolclient/ToolClient.java | 63 +++ .../http/CachedBodyHttpServletRequest.java | 107 +++++ .../biz/mcp/transport/http/McpController.java | 76 ++++ .../transport/http/McpExceptionHandler.java | 87 ++++ .../mcp/transport/http/McpExchangeFilter.java | 214 +++++++++ .../http/McpProtocolVersionValidator.java | 55 +++ .../http/McpRequestContextFactory.java | 135 ++++++ src/main/resources/application-local.yml | 17 + src/main/resources/application-ocp.yml | 13 + src/main/resources/application.yml | 91 ++++ src/main/resources/logback-spring.xml | 15 + .../dap/biz/mcp/McpServerApplicationTest.java | 18 + .../shinhanlife/dap/biz/mcp/TestFixtures.java | 74 +++ .../config/McpBundleConfigurationTest.java | 98 ++++ .../AgentBuilderContractExampleTest.java | 223 +++++++++ .../ToolBundleContractExampleTest.java | 146 ++++++ .../deploy/HelmDeploymentContractTest.java | 338 ++++++++++++++ .../ArchitectureDocumentContractTest.java | 74 +++ .../biz/mcp/docs/CodeStyleContractTest.java | 215 +++++++++ .../mcp/docs/PackageBoundaryContractTest.java | 112 +++++ .../execute/ToolArgumentValidatorTest.java | 77 ++++ .../mcp/execute/ToolExecutionServiceTest.java | 69 +++ .../mcp/execute/ToolRoutingServiceTest.java | 31 ++ .../mcp/jsonrpc/JsonRpcRequestParserTest.java | 49 ++ .../biz/mcp/method/InitializeHandlerTest.java | 44 ++ .../InitializedNotificationHandlerTest.java | 24 + .../biz/mcp/method/ToolsCallHandlerTest.java | 177 ++++++++ .../biz/mcp/method/ToolsListHandlerTest.java | 114 +++++ .../HealthGroupContractTest.java | 85 ++++ .../ToolBundleStatusEndpointTest.java | 28 ++ .../ToolCatalogHealthIndicatorTest.java | 51 +++ .../mcp/observability/TraceLoggerTest.java | 44 ++ .../LocalFileToolRegistryClientTest.java | 32 ++ .../registry/RedisToolRegistryCacheTest.java | 61 +++ .../mcp/registry/ToolBundleDiscoveryTest.java | 351 ++++++++++++++ .../ToolBundleRegistryWiringTest.java | 49 ++ .../mcp/registry/ToolRegistryServiceTest.java | 171 +++++++ .../mcp/toolclient/HttpToolClientTest.java | 87 ++++ .../mcp/transport/http/McpControllerTest.java | 103 +++++ .../http/McpEndpointMethodContractTest.java | 131 ++++++ .../http/McpExceptionHandlerTest.java | 114 +++++ .../transport/http/McpExchangeFilterTest.java | 303 +++++++++++++ .../http/McpProtocolVersionValidatorTest.java | 57 +++ 168 files changed, 12057 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 build.gradle create mode 100644 config/local-core-tools-manifest-sample-v1.json create mode 100644 config/local-information-tools-manifest-sample-v1.json create mode 100644 config/local-process-tools-manifest-sample-v1.json create mode 100644 deploy/README.md create mode 100644 deploy/helm/mcp-server/Chart.yaml create mode 100644 deploy/helm/mcp-server/templates/_helpers.tpl create mode 100644 deploy/helm/mcp-server/templates/configmap.yaml create mode 100644 deploy/helm/mcp-server/templates/deployment.yaml create mode 100644 deploy/helm/mcp-server/templates/networkpolicy.yaml create mode 100644 deploy/helm/mcp-server/templates/poddisruptionbudget.yaml create mode 100644 deploy/helm/mcp-server/templates/route.yaml create mode 100644 deploy/helm/mcp-server/templates/service.yaml create mode 100644 deploy/helm/mcp-server/values-dev.yaml create mode 100644 deploy/helm/mcp-server/values-prod.yaml create mode 100644 deploy/helm/mcp-server/values-test.yaml create mode 100644 deploy/helm/mcp-server/values.yaml create mode 100644 docs/architecture.md create mode 100644 docs/codex-workflow.md create mode 100644 docs/contracts/agent-builder-mcp/README.md create mode 100644 docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-request.json create mode 100644 docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-response.json create mode 100644 docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialized-notification.json create mode 100644 docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/tools-call-execution-error-response.json create mode 100644 docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/tools-call-request.json create mode 100644 docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/tools-call-success-response.json create mode 100644 docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/initialize-response.json create mode 100644 docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-execution-error-response.json create mode 100644 docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-invalid-params-response.json create mode 100644 docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-request.json create mode 100644 docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-success-response.json create mode 100644 docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-list-response.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-initialize-request.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-initialize-response.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-initialized-notification.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-tools-call-request.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-tools-call-response.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316.md create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-initialize-request.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-initialize-response.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-initialized-notification.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-tools-call-request.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-tools-call-response.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324.md create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-initialize-request.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-initialize-response.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-initialized-notification.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-tools-call-request.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-tools-call-response.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331.md create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-initialize-request.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-initialize-response.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-tools-call-request.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-tools-call-response-visible-transcript.txt create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search.md create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-initialize-request.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-initialize-response.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-initialized-notification.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-tools-call-request.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-tools-call-response.json create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections.md create mode 100644 docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/README.md create mode 100644 docs/contracts/agent-builder-mcp/protocol-v0.2-agentbuilder.md create mode 100644 docs/contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md create mode 100644 docs/contracts/agent-builder-mcp/protocol-v1-agreement-baseline.md create mode 100644 docs/contracts/tool-service-mcp/README.md create mode 100644 docs/contracts/tool-service-mcp/TEMP-tool-list-loading-guide.md create mode 100644 docs/contracts/tool-service-mcp/examples/bundle-v0.2/bundle-status-response.json create mode 100644 docs/contracts/tool-service-mcp/examples/bundle-v0.2/manifest-response.json create mode 100644 docs/contracts/tool-service-mcp/examples/bundle-v0.2/mcp-bundle-config.yaml create mode 100644 docs/contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md create mode 100644 docs/decisions/ADR-0001-stateless-execution-boundary.md create mode 100644 docs/decisions/ADR-0002-tool-exposure-and-single-call.md create mode 100644 docs/decisions/ADR-0003-builder-tool-uid.md create mode 100644 docs/decisions/ADR-0004-execution-guardrails.md create mode 100644 docs/decisions/ADR-0005-standard-tool-name.md create mode 100644 docs/decisions/ADR-0006-no-authentication-in-mcp.md create mode 100644 docs/decisions/ADR-0007-one-mcp-per-tool-service.md create mode 100644 docs/decisions/ADR-0008-shared-host-path-routing.md create mode 100644 docs/decisions/ADR-0009-container-handles-public-mcp-path.md create mode 100644 docs/decisions/README.md create mode 100644 docs/discussions/DISC-20260710-001-agentbuilder-mcp-interface.md create mode 100644 docs/discussions/README.md create mode 100644 docs/extension-points.md create mode 100644 docs/mcp-java-sdk-adoption.md create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/McpServerApplication.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/config/HttpClientConfig.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContext.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContextHolder.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidator.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolCall.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionService.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingService.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcErrorCode.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcException.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequest.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParser.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcResponse.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandler.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandler.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/method/McpMethodHandlerRegistry.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandler.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandler.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolBundleStatusEndpoint.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/observability/TraceLogger.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClient.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCache.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscovery.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryClient.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClient.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/ToolClient.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/CachedBodyHttpServletRequest.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpController.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandler.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilter.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidator.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRequestContextFactory.java create mode 100644 src/main/resources/application-local.yml create mode 100644 src/main/resources/application-ocp.yml create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/McpServerApplicationTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/contract/AgentBuilderContractExampleTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/contract/ToolBundleContractExampleTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/deploy/HelmDeploymentContractTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/docs/ArchitectureDocumentContractTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/docs/CodeStyleContractTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/docs/PackageBoundaryContractTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidatorTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionServiceTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingServiceTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParserTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandlerTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandlerTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandlerTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandlerTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/observability/HealthGroupContractTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolBundleStatusEndpointTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/observability/TraceLoggerTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClientTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCacheTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryWiringTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClientTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpControllerTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpEndpointMethodContractTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandlerTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidatorTest.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2b5cf2d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,24 @@ +# 줄바꿈 정책을 저장소가 소유한다. +# 이 파일이 있으면 각자의 core.autocrlf 설정과 무관하게 동일한 결과가 나온다. + +# 기본: 텍스트로 판단되면 저장소에는 항상 LF로 보관한다. +* text=auto + +# 반드시 LF여야 한다. CRLF가 섞이면 Linux/컨테이너에서 실행되지 않는다. +*.java text eol=lf +gradlew text eol=lf +*.sh text eol=lf + +# 반드시 CRLF여야 한다. Windows 셸이 해석하는 파일이다. +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# 바이너리: diff와 줄바꿈 변환 대상에서 제외한다. +*.jar binary +*.png binary +*.jpg binary +*.pdf binary +*.pptx binary +*.p12 binary +*.jks binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6417248 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +.gradle/ +build/ +out/ +.idea/* +# 코드 스타일은 저장소가 소유한다. 이 파일이 빠지면 새로 clone한 곳에서 +# 서식 기준이 사라지고, build.gradle의 ideaFormat 작업도 대상 파일을 찾지 못한다. +!.idea/codeStyles/ +!.idea/codeStyles/** +*.iml +*.log +.tools/ +output/ +outputs/ +tmp/ +.agents/ +.claude/ +.idea/codeStyles/ +AGENTS.md + +# 에이전트 작업 계획·산출물. 저장소 문서가 아니므로 반입 대상에 넣지 않는다. +# 결정은 docs/decisions/의 ADR에, 규칙은 계약 테스트에 남긴다(AGENTS.md 4절). +docs/superpowers/ + +# Local configuration and secrets +.env +.env.* +!.env.example +*.pem +*.key +*.p12 +*.pfx +*.jks +*.keystore +secrets/ +.codex/config.toml +.codex/local/ + +# Claude Code: 공유 설정(.claude/settings.json)은 커밋하고 개인 설정은 제외한다. +.claude/settings.local.json + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c78a28f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM eclipse-temurin:21-jre + +WORKDIR /opt/app +COPY build/libs/ax-hub-mcp-server.jar app.jar + +EXPOSE 8080 +USER 1001 + +ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "/opt/app/app.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c8d341 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# AX HUB MCP Server + +Agent Builder와 Tool Service 사이의 stateless MCP 실행 계층이다. Agent Builder가 `tools/call`에 명시한 단일 Tool을 JSON-RPC 2.0과 `inputSchema`로 검증하고, 서버가 관리하는 metadata에 따라 Tool Service를 호출한다. + +이 서버는 Tool을 추천하거나 사용자 의도를 판단하지 않는다. 업무 규칙은 Tool Service가, Tool 선택과 사용자·Agent별 노출 정책은 Agent Builder가 소유한다. + +## 기술 기준 + +- Java 21, Spring Boot 4.0.7, Spring MVC +- MCP Java SDK 2.0.0의 `mcp-json-jackson3`: protocol 상수·표준 result 모델·JSON Schema 검증에만 사용 +- Spring Data Redis: 선택적 공유 cache +- Spring AI MCP Starter/transport: 사용하지 않음 + +SDK 적용 경계는 [MCP Java SDK 선택적 도입 설계](docs/mcp-java-sdk-adoption.md)를 따른다. + +## 빠른 시작 + +필수 조건은 JDK 21이다. 기본 profile은 `local`이며 Redis 없이 Tool Service 매니페스트를 먼저 조회하고, 최초 조회 실패 시 `config/local-core-tools-manifest-sample-v1.json`을 fallback으로 사용한다. + +```powershell +.\gradlew.bat check +.\gradlew.bat bootRun +``` + +**빌드는 외부 저장소에서 코드 스타일 도구를 내려받지 않는다.** 폐쇄망에서 검사 하나 때문에 빌드 전체가 시작되지 못하는 상황을 만들지 않기 위해서다. 서식 검사는 저장소 안의 테스트가 소유한다. + +Java 포맷은 `.idea/codeStyles/Project.xml`의 IntelliJ IDEA 코드 스타일로 고정한다. 이 파일은 저장소에 포함되어 있어 IDE에서 자동으로 적용된다. Java 소스의 줄바꿈은 운영체제와 무관하게 LF이며 `.gitattributes`가 commit 시점에 이를 강제한다. + +두 가지가 보장하는 범위가 다르다. + +| 무엇이 | 보장하는 것 | 조건 | +|---|---|---| +| `CodeStyleContractTest` | LF 줄바꿈, 탭 없음, 후행 공백 없음, 파일 끝 개행, 미사용 import 없음 | 항상 (`test`에 포함) | +| IntelliJ formatter | 4칸 들여쓰기, 줄바꿈 스타일, 단순 lambda·다중 표현식 분리 | `IDEA_FORMATTER` 설정 시에만 | + +**`IDEA_FORMATTER`가 없으면 IntelliJ formatter 단계는 경고를 남기고 건너뛴다.** IntelliJ가 없는 CI나 폐쇄망 빌드에서 빌드가 깨지지 않게 하기 위한 것이며, 그 환경에서는 들여쓰기와 줄바꿈이 검증되지 않는다는 뜻이다. **도구 없이 판정할 수 있는 규칙은 그때도 계속 검사된다.** + +포맷터로 코드를 실제로 정리하려면 경로를 지정하고 `ideaFormat`을 실행한다. `CodeStyleContractTest`는 검사만 하고 고쳐 주지 않는다. + +```powershell +$env:IDEA_FORMATTER='C:/Program Files/JetBrains/IntelliJ IDEA 2026.1.4/bin/format.bat' +.\gradlew.bat ideaFormat +``` + +줄 폭 160자는 코드 스타일의 권장값이며 기존 코드에 소급 적용되지 않는다. 강제 대상이 아니다. + +local Tool 파일을 바꾸려면 다음 환경변수에 Spring resource 경로를 지정한다. + +```powershell +$env:MCP_LOCAL_TOOL_REGISTRY_FILE='file:C:/path/local-tools.json' +``` + +## 공개 계약 + +- 공개 endpoint: `POST https://{global.mcpHost}{deployments..publicPath}` +- 예: `https://mcp-dev.apps.example.internal/mcp/processing-critical` +- OpenShift Route는 공개 path로 MCP Service만 선택하고, 컨테이너가 같은 path를 직접 처리 +- Method: `initialize`, `notifications/initialized`, `tools/list`, `tools/call` +- Response: 항상 단일 `application/json` JSON-RPC response +- `Accept: application/json, text/event-stream`: 호환 목적으로 수용하지만 SSE 경로는 제공하지 않음 +- 공개 endpoint의 `GET`: `405 Method Not Allowed` +- `MCP-Protocol-Version`: `initialize` 이후 필수, 현재 `2025-06-18` +- `Mcp-Session-Id`: initialize lifecycle 추적용 correlation 값이며 서버 세션이 아님 + +정확한 요청·응답과 오류 의미는 [Agent Builder-MCP 현재 계약 v0.3](docs/contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md)이 정본이다. +Agent Builder는 공개 URL마다 별도 MCP로 등록하고 initialize한다. URL 사이에 lifecycle correlation이나 Tool 목록을 공유하지 않는다. + +## Tool metadata와 실행 + +| 환경 | Tool 원천 | Redis | +|---|---|---| +| `local` | local JSON fixture | 사용 안 함 | +| 운영(`ocp`) | 이 배포가 보는 Tool Service 매니페스트를 주기적으로 pull | 성공 snapshot 공유와 warm start에만 사용 | + +요청 경로의 `tools/list`와 `tools/call`은 in-memory snapshot만 읽는다. 운영 refresh는 bundle별 last-good을 유지하고, 모든 bundle에 사용 가능한 성공본이 있을 때만 aggregate를 교체한다. 조회 실패만으로 Tool을 제거하지 않으며 정상 매니페스트에서 삭제가 확인될 때만 반영한다. 코드는 bundle N개 병합을 지원하지만 **운영 배포의 bundle은 항상 하나다**([ADR-0007](docs/decisions/ADR-0007-one-mcp-per-tool-service.md)). + +Tool 실행 주소는 local `_meta.endpoint` 또는 운영 `baseEndpoint` 설정에서만 정한다. Agent Builder의 `arguments`와 Tool Service 매니페스트는 호출 대상을 바꿀 수 없다. + +운영 매니페스트와 장애 처리의 wire 계약은 [Tool Service-MCP bundle 조회 계약 v0.2](docs/contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md)를 따른다. + +## Correlation과 로그 + +호출자가 보내는 헤더는 다섯 개이며 **모두 선택값**이다. 값은 그대로 Tool Service 요청 헤더로 bypass한다. + +| 헤더 | 의미 | 없을 때 | +|---|---|---| +| `guid` | 요청 하나를 끝까지 따라가는 상관 값(UUID) | 서버가 생성 | +| `x-request-id` | 개별 HTTP 요청 ID | 서버가 생성 | +| `mcp-session-id` | initialize lifecycle 상관 값 | 전달하지 않음 | +| `employee-no` | 암호화된 사원번호 | 전달하지 않음 | +| `virtual-employee-no` | 암호화된 가상사원번호(상담사 등 비사원) | 전달하지 않음 | + +`employee-no`와 `virtual-employee-no`는 **MCP가 복호화하지 않는 불투명 값**이다. 형식이나 의미를 해석하지 않고, 개행이 섞여 downstream 헤더가 조작되는 것만 막은 뒤 그대로 전달한다. + +MDC는 사용하지 않는다. 로그에는 `guid`와 `x-request-id`만 남기며 **사원 식별자는 암호문이라도 기록하지 않는다.** request/response body와 credential도 남기지 않는다. + +`Authorization`은 `mcp.tool-client.forward-authorization` 설정이 켜진 경우에만 전달한다. MCP는 이 값을 해석하지 않는다. + +## 인증과 권한 + +**이 서버는 인증도 인가도 하지 않는다.** 요청자 신원을 검증하지 않고, Tool 실행 권한을 판단하지 않으며, 사원 식별자를 복호화하지 않는다. 결정과 근거는 [ADR-0006](docs/decisions/ADR-0006-no-authentication-in-mcp.md)이다. + +| 책임 | 주체 | +|---|---| +| 외부 호출자를 Agent Builder로 제한 | OpenShift Route IP allowlist | +| MCP Pod 직접 접근 제한 | 플랫폼 NetworkPolicy | +| 사용자 인증과 Tool 실행 권한 | Agent Builder | +| 사원 식별자 복호화(KMS)와 업무 권한 | Tool Service | + +⚠️ **Route IP allowlist와 NetworkPolicy는 선택 사항이 아니다.** 외부 요청은 Route가 Agent Builder의 고정 egress CIDR만 받고, backend 요청은 ingress controller 또는 허용된 Agent Builder namespace에서만 MCP Pod에 도달한다. 실제 CIDR을 넣지 않은 배포는 운영에 사용할 수 없다. `HelmDeploymentContractTest`가 두 경계가 Chart에서 빠지지 않도록 고정한다. + +## 운영 설정 + +운영 설정은 Helm Chart가 만드는 ConfigMap이 담당한다. `identity`와 bundle 설정을 환경변수로 나열하지 않는 이유는 항목이 흩어질수록 인덱스 실수가 조용한 오라우팅이 되기 때문이다. + +`identity`는 `{배포 이름}-{global.env}`로 template이 조립한다. 현재 Redis cache 구현이 이 값을 사용하지만, Redis key namespace와 공유 정책은 아직 확정되지 않았으므로 [extension-points.md](docs/extension-points.md#운영-적용-전-필수-보완)에서 합의한다. + +- 업무 포트: `SERVER_PORT`(기본 8080) +- management 포트: `MANAGEMENT_SERVER_PORT`(운영 기본 9090) +- 상태: `/actuator/health/liveness`, `/actuator/health/readiness` +- bundle 진단: management 포트의 `/actuator/toolBundles` + +readiness는 첫 Tool discovery 시도가 끝나고 usable in-memory snapshot이 있을 때만 UP이다. 원천 장애 중에도 +기존 memory 또는 Redis last-good이 있으면 서비스를 유지하고, 아무 성공본도 없으면 트래픽을 받지 않는다. + +**MCP 배포 하나는 Tool Service 하나만 본다**([ADR-0007](docs/decisions/ADR-0007-one-mcp-per-tool-service.md)). 대상을 늘리는 방법은 bundle 목록을 늘리는 것이 아니라 배포를 하나 더 만드는 것이다. 외부에서는 같은 host의 고유 path로 각 배포를 노출하고 컨테이너가 그 path를 그대로 처리한다([ADR-0009](docs/decisions/ADR-0009-container-handles-public-mcp-path.md)). 배포는 업무 × 중요도 등급으로 나뉘며, 등급이 replica 수와 PodDisruptionBudget을 정한다. + +배포 정의는 [Helm Chart](deploy/helm/mcp-server/) 하나뿐이다. 배포 토폴로지는 `values.yaml`이, 환경 차이는 `values-{dev,test,prod}.yaml`이 소유한다. 설치할 배포 하나는 `--set`으로 고른다. + +```bash +helm upgrade --install processing-critical-mcp deploy/helm/mcp-server -f deploy/helm/mcp-server/values-dev.yaml --set deploymentKey=processing-critical -n +``` + +**MCP Server와 Tool Service는 같은 namespace에 배포한다.** 그래서 values에는 Tool Service의 이름만 적고 주소는 template이 조립한다. 환경마다 URL을 반복해 적지 않으므로 오타로 엉뚱한 곳을 호출할 수 없다. + +```yaml +deployments: + processing-critical: + name: processing-critical-mcp + service: processing-critical-tools # ← 이름만. 주소는 template이 만든다 + namePrefix: "processing." # ← 업무 단위. 등급을 넣지 않는다 + tier: critical + publicPath: /mcp/processing-critical # ← 같은 환경 host 안에서 유일 +``` + +배포가 10개든 20개든 파일 수는 늘지 않는다. 자세한 사용법은 [deploy/README.md](deploy/README.md)에 있다. + +평문 manifest가 필요하면 `helm template`으로 만든다. 별도 YAML을 저장소에 두지 않는다 — 두 벌은 반드시 어긋난다. + +빌드·이미지·배포 실행 방식은 사내 표준 CI/CD가 담당하며 이 저장소가 정하지 않는다. 배포 시 알아야 할 앱 제약은 [deploy/README.md](deploy/README.md)에 정리했다. + +## 문서 R&R + +| 문서 | 책임 | +|---|---| +| [README](README.md) | 프로젝트 진입점과 실행 방법 | +| [architecture.md](docs/architecture.md) | 현재 코드 구조, 요청 흐름, 내부 책임과 장애 동작 | +| [Agent Builder-MCP contracts](docs/contracts/agent-builder-mcp/README.md) | Agent Builder와의 HTTP/JSON-RPC wire 계약 | +| [Tool Service-MCP contracts](docs/contracts/tool-service-mcp/README.md) | 매니페스트와 Tool 실행 wire 계약 | +| [decisions](docs/decisions/README.md) | 결정 이유와 대안 이력 | +| [extension-points.md](docs/extension-points.md) | 아직 미합의인 항목과 운영 보완 작업 | +| [codex-workflow.md](docs/codex-workflow.md) | 저장소 작업 규칙과 공개 정책 | + +Superseded/Rejected 문서는 이력일 뿐 현재 구현 근거가 아니다. 코드나 공개 계약을 변경할 때는 가까운 테스트와 해당 현재 계약을 함께 수정한다. 변경을 마치기 전에 실행할 검증 명령은 [AGENTS.md](AGENTS.md)의 완료 기준이 정본이다. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..071d0c4 --- /dev/null +++ b/build.gradle @@ -0,0 +1,112 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.7' + id 'io.spring.dependency-management' version '1.1.7' +} + +// LF 줄바꿈, 후행 공백, 파일 끝 개행, 미사용 import 검사는 CodeStyleContractTest가 소유한다. +// 이전에는 Spotless 플러그인이 했지만, plugins 블록의 플러그인은 빌드를 읽는 시점에 +// 외부 저장소에서 내려받아야 해서 폐쇄망에서는 검사 하나 때문에 빌드 전체가 시작되지 못한다. +// 규칙을 테스트로 옮겨 외부 의존성 없이 같은 것을 지킨다. + +def configureIdeaFormatter = { Exec task, boolean dryRun -> + task.group = 'formatting' + task.inputs.files(fileTree('src/main/java'), fileTree('src/test/java')) + task.inputs.file('.idea/codeStyles/Project.xml') + // The IntelliJ formatter is an external binary, so it cannot be a hard build dependency: + // CI agents and the closed-network build host may not have the IDE installed. Skip with a + // loud warning instead of failing, so `check` still runs CodeStyleContractTest, which owns + // the checks that need no tooling (line endings, trailing whitespace, final newline, unused imports). + task.onlyIf { + if (System.getenv('IDEA_FORMATTER')) { + return true + } + task.logger.warn( + "[{}] SKIPPED: IDEA_FORMATTER is not set, so indentation and wrapping are NOT verified. " + + "Set it to IntelliJ IDEA's bin/format.bat (Windows) or bin/format.sh to enable this check.", + task.name) + false + } + task.doFirst { + def formatter = System.getenv('IDEA_FORMATTER') + def formatterHome = layout.buildDirectory.dir('idea-formatter').get().asFile + formatterHome.mkdirs() + def ideaProperties = new File(formatterHome, 'idea.properties') + def normalizedHome = formatterHome.absolutePath.replace('\\', '/') + ideaProperties.text = """idea.config.path=${normalizedHome}/config +idea.system.path=${normalizedHome}/system +idea.log.path=${normalizedHome}/log +idea.plugins.path=${normalizedHome}/plugins +""" + task.environment 'IDEA_PROPERTIES', ideaProperties.absolutePath + + def arguments = ['-s', file('.idea/codeStyles/Project.xml').absolutePath, '-charset', 'UTF-8'] + if (dryRun) { + arguments << '-d' + } + arguments.addAll(['-r', file('src/main/java').absolutePath, file('src/test/java').absolutePath]) + if (System.getProperty('os.name').toLowerCase().contains('windows')) { + task.commandLine(['cmd', '/c', formatter] + arguments) + } else { + task.commandLine([formatter] + arguments) + } + } +} + +tasks.register('ideaFormat', Exec) { + description = 'Formats all Java sources with the IntelliJ IDEA project code style.' + configureIdeaFormatter(delegate, false) +} + +tasks.register('ideaFormatCheck', Exec) { + description = 'Checks all Java sources with the IntelliJ IDEA project code style.' + configureIdeaFormatter(delegate, true) +} + +// `check`는 `test`를 이미 포함하므로 CodeStyleContractTest가 함께 돈다. +// 여기에 IntelliJ 포맷터 검사를 붙여, 검증 명령 하나로 서식과 동작을 모두 확인한다. +tasks.named('check') { + dependsOn 'ideaFormatCheck' +} + +group = 'io.shinhanlife.dap.biz.mcp' +version = '0.1.0' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // This module supplies the MCP protocol models transitively and the Jackson 3 schema validator directly. + // The MCP server starter/transport is intentionally excluded because this project owns the /mcp HTTP contract. + implementation 'io.modelcontextprotocol.sdk:mcp-json-jackson3:2.0.0' + + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} + +tasks.withType(JavaCompile).configureEach { + options.compilerArgs += ['-Xlint:deprecation', '-Xlint:unchecked'] +} + +tasks.named('bootJar') { + archiveFileName = 'ax-hub-mcp-server.jar' +} diff --git a/config/local-core-tools-manifest-sample-v1.json b/config/local-core-tools-manifest-sample-v1.json new file mode 100644 index 0000000..fafcc0f --- /dev/null +++ b/config/local-core-tools-manifest-sample-v1.json @@ -0,0 +1,29 @@ +{ + "bundleId": "core", + "revision": "sample-v10", + "tools": [ + { + "name": "core.weather", + "title": "날씨 조회", + "description": "로컬 매니페스트 fallback 동작을 확인하는 테스트 도구입니다.", + "inputSchema": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "조회할 도시명" + } + }, + "required": [ + "city" + ], + "additionalProperties": false + }, + "_meta": { + "version": "sample-v1", + "endpoint": "http://localhost:18080/mcp", + "enabled": true + } + } + ] +} diff --git a/config/local-information-tools-manifest-sample-v1.json b/config/local-information-tools-manifest-sample-v1.json new file mode 100644 index 0000000..e315548 --- /dev/null +++ b/config/local-information-tools-manifest-sample-v1.json @@ -0,0 +1,24 @@ +{ + "bundleId": "information", + "revision": "sample-v1", + "tools": [ + { + "name": "information.weather", + "title": "날씨 조회", + "description": "로컬 매니페스트 fallback 동작을 확인하는 테스트 도구입니다.", + "inputSchema": { + "type": "object", + "properties": { + "city": { "type": "string", "description": "조회할 도시명" } + }, + "required": ["city"], + "additionalProperties": false + }, + "_meta": { + "version": "sample-v1", + "endpoint": "http://localhost:18080/mcp", + "enabled": true + } + } + ] +} diff --git a/config/local-process-tools-manifest-sample-v1.json b/config/local-process-tools-manifest-sample-v1.json new file mode 100644 index 0000000..e6fcf40 --- /dev/null +++ b/config/local-process-tools-manifest-sample-v1.json @@ -0,0 +1,24 @@ +{ + "bundleId": "process", + "revision": "sample-v1", + "tools": [ + { + "name": "process.weather", + "title": "날씨 조회", + "description": "로컬 매니페스트 fallback 동작을 확인하는 테스트 도구입니다.", + "inputSchema": { + "type": "object", + "properties": { + "city": { "type": "string", "description": "조회할 도시명" } + }, + "required": ["city"], + "additionalProperties": false + }, + "_meta": { + "version": "sample-v1", + "endpoint": "http://localhost:18080/mcp", + "enabled": true + } + } + ] +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..03e4615 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,132 @@ +# 배포 정의 + +이 디렉터리는 **배포될 대상**을 정의한다. 빌드·이미지·배포 실행 방식은 사내 표준 CI/CD가 담당하며 +이 저장소가 정하지 않는다. + +## Helm Chart + +[helm/mcp-server/](helm/mcp-server/)가 유일한 배포 정의다. values는 두 축으로 나뉜다. + +| 파일 | 소유하는 것 | +|---|---| +| `values.yaml` | **배포 토폴로지.** 어떤 MCP가 어떤 Tool Service를 보는가, 공개 path, 가용성 등급 | +| `values-{dev,test,prod}.yaml` | **환경 차이.** namespace, 이미지, 공개 host·허용 CIDR, 등급별 replica·PDB, 리소스 | + +설치할 때 두 번째 축을 `-f`로, 첫 번째 축에서 고를 배포 하나를 `--set deploymentKey=`로 지정한다. + +```bash +helm upgrade --install processing-critical-mcp helm/mcp-server -f helm/mcp-server/values-dev.yaml --set deploymentKey=processing-critical -n +``` + +`deploymentKey`에는 기본값이 없다. 지정을 빠뜨리면 렌더링 단계에서 멈춘다. +엉뚱한 배포가 조용히 설치되는 것보다 낫다. + +### 공유 host와 배포별 path + +[ADR-0009](../docs/decisions/ADR-0009-container-handles-public-mcp-path.md)에 따라 한 환경은 하나의 공개 host를 사용하고, +각 Helm release는 고유 path의 OpenShift Route를 만든다. Route는 Service만 선택하고 공개 path를 그대로 +전달하며, 컨테이너가 같은 path를 직접 처리한다. + +```text +https://mcp-dev.apps.example.internal/mcp/processing-critical -> processing-critical-mcp:8080/mcp/processing-critical +https://mcp-dev.apps.example.internal/mcp/information-standard -> information-standard-mcp:8080/mcp/information-standard +``` + +공개 URL은 각각 독립된 MCP다. Agent Builder는 URL별로 등록하고 initialize하며, 한 Route나 MCP Pod의 +장애가 다른 path의 Deployment로 전파되지 않는다. + +### MCP 하나는 Tool Service 하나만 본다 + +[ADR-0007](../docs/decisions/ADR-0007-one-mcp-per-tool-service.md)의 결정이다. 대상을 늘리는 방법은 +bundle 목록을 늘리는 것이 아니라 **배포를 하나 더 만드는 것**이다. + +```yaml +deployments: + processing-critical: + name: processing-critical-mcp + service: processing-critical-tools # ← 이름만. 주소는 template이 만든다 + namePrefix: "processing." # ← 업무 단위. 등급을 넣지 않는다 + tier: critical + publicPath: /mcp/processing-critical # ← 환경 host 안에서 유일 +``` + +배포가 10개든 20개든 **파일 수는 늘지 않는다.** 전체 매핑을 한 화면에서 검토할 수 있고, +`--set`으로 고르는 값 하나만 배포마다 달라진다. + +MCP Server와 Tool Service는 같은 namespace에 배포하므로 values에는 서비스 이름만 적고 +주소는 template이 조립한다. 환경마다 URL을 반복하지 않으므로 오타로 다른 대상을 호출할 수 없다. + +`identity`도 `{배포 이름}-{global.env}`로 template이 조립한다. 현재 Redis cache 구현이 이 값을 +사용하지만, key namespace와 공유 정책은 아직 확정되지 않았다. + +### 가용성 등급 + +배포를 업무 × 중요도로 나누는 목적은 **중요 등급에만 비용을 쓰기 위해서**다. + +```yaml +tiers: + critical: { replicas: 3, podDisruptionBudget: true, spreadAcrossNodes: true } + standard: { replicas: 2, podDisruptionBudget: false, spreadAcrossNodes: false } +``` + +test와 prod의 `critical`은 **replica 2 이상, PodDisruptionBudget, 노드 분산 설정이 필수**다. replica가 +1이면 rolling update 중 반드시 공백이 생기고, PDB가 없으면 노드 drain이 마지막 Pod을 내릴 수 있다. +`HelmDeploymentContractTest`는 values와 template의 정적 규칙을 검사한다. dev는 배포마다 Pod 1개로 +운영하므로 이 검사 대상이 아니다. + +정적 테스트는 Helm 렌더러를 실행하지 않는다. 실제 배포 파이프라인은 사용하는 환경과 등급별로 +`helm lint`와 `helm template`을 실행해 병합된 values와 생성 YAML을 확인해야 한다. + +```bash +helm lint helm/mcp-server -f helm/mcp-server/values-prod.yaml --set deploymentKey=processing-critical +helm template processing-critical-mcp helm/mcp-server -f helm/mcp-server/values-prod.yaml --set deploymentKey=processing-critical +helm template processing-standard-mcp helm/mcp-server -f helm/mcp-server/values-prod.yaml --set deploymentKey=processing-standard +``` + +**나누는 것만으로 가용성이 생기지는 않는다.** 같은 노드 배치, namespace 쿼터, 공통 Redis·클러스터 +장애는 분할로 막히지 않는다. 남은 작업은 [extension-points.md](../docs/extension-points.md)의 +"운영 적용 전 필수 보완"에서 관리한다. + +### dev에서 MCP에 연결되지 않을 때 + +**먼저 Tool Service가 떠 있는지 확인한다.** readiness가 usable snapshot을 요구하므로, Tool Service가 +없으면 MCP Pod은 Ready가 되지 못하고 Service endpoint에서 빠진다. dev는 배포마다 Pod 1개라 +그 순간 그 MCP로는 아예 연결되지 않는다. "MCP가 죽었다"가 아니라 "읽을 Tool이 없다"는 뜻이다. + +```bash +kubectl get pod -l app=<배포 이름> # 0/1 Ready이면 이 경우다 +kubectl describe pod # Readiness probe 실패 사유 +kubectl port-forward 9090:9090 # /actuator/toolBundles로 bundle 상태 확인 +``` + +Tool Service가 뜨면 다음 refresh 주기(기본 30초) 안에 스스로 Ready가 된다. 재기동할 필요가 없다. +`/actuator/toolBundles`는 management 포트라 NetworkPolicy가 관제 namespace로 제한하므로, +개발자는 위처럼 `port-forward`로 본다. + +## 확정 전 임시값 + +`values.yaml`의 Tool Service 이름·이미지 경로와 `values-{env}.yaml`의 namespace·공개 host·Route 허용 CIDR은 자리표시자다. +각 파일의 `TODO` 주석을 참고해 확정 시 교체하고, 존재하지 않는 배포는 `deployments`에서 삭제한다. + +## 배포 시 알아야 할 앱 제약 + +아래는 이 애플리케이션의 동작에서 나온 사실이다. 각 항목의 정본은 링크한 문서이며 값을 여기 옮겨 적지 않는다. + +| 제약 | 정본 | +|---|---| +| readiness·liveness는 management 포트에서 제공한다 | [architecture.md](../docs/architecture.md) | +| readiness는 첫 Tool 조회 시도 완료 후 usable snapshot이 있을 때만 UP이다 | [architecture.md](../docs/architecture.md) | +| `terminationGracePeriodSeconds`는 Spring drain보다 길어야 한다 | [architecture.md의 요청 시간 예산](../docs/architecture.md#요청-시간-예산) | +| **NetworkPolicy는 필수다. 비활성화 스위치를 두지 않았다** | [ADR-0006](../docs/decisions/ADR-0006-no-authentication-in-mcp.md) | +| 공개 path는 Route와 컨테이너 endpoint가 동일하게 사용한다 | [ADR-0009](../docs/decisions/ADR-0009-container-handles-public-mcp-path.md) | +| Redis key·TTL·공유 정책은 확정 전이다 | [extension-points.md](../docs/extension-points.md#운영-적용-전-필수-보완) | +| bundle은 정확히 하나다 | [ADR-0007](../docs/decisions/ADR-0007-one-mcp-per-tool-service.md) | + +Route IP allowlist와 NetworkPolicy는 특히 중요하다. 이 서버는 인증·인가를 하지 않으므로 `/mcp`에 +도달할 수 있다는 것이 곧 인가다. Route는 Agent Builder 고정 egress CIDR만 받고, NetworkPolicy는 Route +backend인 ingress controller와 명시한 Agent Builder namespace만 업무 포트에 허용한다. + +## 미확정 항목 + +배포 정의를 이 저장소가 어디까지 소유하는지, namespace·registry 명명 규칙은 아직 확정되지 않았다. +[docs/extension-points.md](../docs/extension-points.md)에서 관리한다. diff --git a/deploy/helm/mcp-server/Chart.yaml b/deploy/helm/mcp-server/Chart.yaml new file mode 100644 index 0000000..a39b0e5 --- /dev/null +++ b/deploy/helm/mcp-server/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: mcp-server +description: AX HUB MCP Server - Agent Builder와 Tool Service 사이의 stateless 실행 계층 +type: application + +# Chart 자체의 버전. 애플리케이션 버전과 따로 올린다. +version: 0.1.0 +# 기본 이미지 tag. 배포 시 values의 image.tag가 덮어쓴다. +appVersion: "0.1.0" diff --git a/deploy/helm/mcp-server/templates/_helpers.tpl b/deploy/helm/mcp-server/templates/_helpers.tpl new file mode 100644 index 0000000..c85d789 --- /dev/null +++ b/deploy/helm/mcp-server/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +설치 대상이 실제로 존재하는지 확인하고, 없으면 읽을 수 있는 메시지로 멈춘다. +검사를 하지 않으면 오타가 "nil pointer" 같은 내부 오류로 나타나 원인을 찾기 어렵다. +값을 반환하지 않으므로 각 template 파일의 첫 줄에서 한 번 부른다. +*/}} +{{- define "mcp-server.validate" -}} +{{- $key := required "deploymentKey를 지정해야 한다. 예: --set deploymentKey=processing-critical" .Values.deploymentKey -}} +{{- $deployment := index .Values.deployments $key -}} +{{- if not $deployment -}} +{{- fail (printf "values.yaml의 deployments에 '%s'가 없다. 오타이거나 아직 토폴로지에 추가하지 않은 배포다." $key) -}} +{{- end -}} +{{- if not (index .Values.tiers $deployment.tier) -}} +{{- fail (printf "values.yaml의 tiers에 '%s' 등급이 없다. deployments의 tier와 tiers의 key가 어긋났다." $deployment.tier) -}} +{{- end -}} +{{- required "global.mcpHost에 환경별 공개 MCP host를 지정해야 한다." .Values.global.mcpHost -}} +{{- required "route.sourceAllowlist에 Agent Builder의 고정 egress CIDR을 지정해야 한다." .Values.route.sourceAllowlist -}} +{{- $publicPath := required (printf "deployments.%s.publicPath를 지정해야 한다." $key) $deployment.publicPath -}} +{{- if not (regexMatch "^/mcp/[a-z0-9-]+$" $publicPath) -}} +{{- fail (printf "deployments.%s.publicPath는 /mcp/<영문 소문자·숫자·하이픈> 형식이어야 한다: %s" $key $publicPath) -}} +{{- end -}} +{{- end -}} + +{{/* +리소스 이름. 하나의 namespace에 여러 MCP 배포가 들어가므로 배포마다 다른 이름을 쓴다. +*/}} +{{- define "mcp-server.name" -}} +{{- include "mcp-server.validate" . -}} +{{- (index .Values.deployments .Values.deploymentKey).name -}} +{{- end -}} + +{{/* +Redis key namespace가 되는 식별자. +환경 접미사를 여기서 자동으로 붙인다. 사람이 values에 직접 적으면 dev/test/prod가 +같은 값을 갖는 실수가 나고, 그 순간 서로의 Tool snapshot을 덮어쓴다. +*/}} +{{- define "mcp-server.identity" -}} +{{- printf "%s-%s" (include "mcp-server.name" .) .Values.global.env -}} +{{- end -}} + +{{/* +이 MCP가 보는 Tool Service의 host:port. +MCP와 Tool Service는 같은 namespace이므로 서비스 이름만으로 FQDN이 완성된다. +호출 대상 주소는 오직 이 설정에서만 온다(계약 v0.2 §1). 매니페스트 응답은 이 값을 바꿀 수 없다. +*/}} +{{- define "mcp-server.toolServiceHost" -}} +{{- include "mcp-server.validate" . -}} +{{- $deployment := index .Values.deployments .Values.deploymentKey -}} +{{- printf "%s.%s.svc.cluster.local:%v" $deployment.service .Release.Namespace .Values.toolService.port -}} +{{- end -}} + +{{- define "mcp-server.labels" -}} +app: {{ include "mcp-server.name" . }} +app.kubernetes.io/name: {{ include "mcp-server.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: mcp-server +app.kubernetes.io/part-of: ax-hub +ax-hub/tier: {{ (index .Values.deployments .Values.deploymentKey).tier }} +{{- end -}} + +{{- define "mcp-server.selectorLabels" -}} +app: {{ include "mcp-server.name" . }} +{{- end -}} diff --git a/deploy/helm/mcp-server/templates/configmap.yaml b/deploy/helm/mcp-server/templates/configmap.yaml new file mode 100644 index 0000000..5c39141 --- /dev/null +++ b/deploy/helm/mcp-server/templates/configmap.yaml @@ -0,0 +1,37 @@ +# 배포별로 달라지는 설정만 담는다. +# 환경과 무관한 기본값(timeout, 상한, management 포트 등)은 jar 안의 application-ocp.yml이 소유하고, +# 이 파일이 같은 이름으로 덮어써 identity와 bundle만 배포 시점에 결정한다. +{{- include "mcp-server.validate" . }} +{{- $deployment := index .Values.deployments .Values.deploymentKey }} +{{- $toolServiceHost := include "mcp-server.toolServiceHost" . }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "mcp-server.name" . }}-config + labels: + {{- include "mcp-server.labels" . | nindent 4 }} +data: + application-ocp.yml: | + mcp: + # "{배포 이름}-{global.env}"로 조립된다. 환경끼리 Redis key가 겹치지 않는다. + identity: {{ include "mcp-server.identity" . }} + # Agent Builder에 등록한 공개 path를 컨테이너가 그대로 처리한다. Route rewrite는 사용하지 않는다. + endpoint-path: {{ $deployment.publicPath | quote }} + + registry: + refreshIntervalSeconds: {{ .Values.mcp.refreshIntervalSeconds }} + refreshJitterSeconds: {{ .Values.mcp.refreshJitterSeconds }} + + discovery: + # 운영 profile은 Tool Service 매니페스트만 원천으로 쓴다. + enabled: true + + # MCP 배포 하나는 Tool Service 하나만 본다(ADR-0007). + # 이 목록은 항상 한 항목이며, 늘리려면 배포를 하나 더 만든다. + # 주소는 여기서 조립한다. values에 URL을 적기 시작하면 오타가 라우팅 사고가 된다. + bundles: + - id: {{ .Values.deploymentKey | quote }} + namePrefix: {{ $deployment.namePrefix | quote }} + manifestUrl: http://{{ $toolServiceHost }}{{ .Values.toolService.manifestPath }} + baseEndpoint: http://{{ $toolServiceHost }}{{ .Values.toolService.basePath }} + enabled: true diff --git a/deploy/helm/mcp-server/templates/deployment.yaml b/deploy/helm/mcp-server/templates/deployment.yaml new file mode 100644 index 0000000..1b9af6c --- /dev/null +++ b/deploy/helm/mcp-server/templates/deployment.yaml @@ -0,0 +1,88 @@ +{{- include "mcp-server.validate" . }} +{{- $tier := index .Values.tiers (index .Values.deployments .Values.deploymentKey).tier }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "mcp-server.name" . }} + labels: + {{- include "mcp-server.labels" . | nindent 4 }} +spec: + # replica 수는 배포가 아니라 가용성 등급이 정한다. 환경별 values의 tiers가 정본이다. + replicas: {{ $tier.replicas }} + selector: + matchLabels: + {{- include "mcp-server.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "mcp-server.labels" . | nindent 8 }} + annotations: + # ConfigMap이 바뀌면 Pod을 다시 굴린다. 이게 없으면 bundle 설정을 고쳐도 + # 기존 Pod이 옛 설정으로 계속 돌아 배포한 줄 알고 넘어가게 된다. + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + spec: + # 진행 중인 tools/call이 잘려 부작용만 남는 것을 줄인다. + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} + {{- if $tier.spreadAcrossNodes }} + affinity: + podAntiAffinity: + # replica를 서로 다른 노드에 두려고 시도한다. required가 아니라 preferred인 이유는 + # 노드가 부족할 때 Pod이 아예 뜨지 못하는 편이 같은 노드에 뜨는 것보다 나쁘기 때문이다. + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + {{- include "mcp-server.selectorLabels" . | nindent 20 }} + {{- end }} + containers: + - name: mcp-server + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.ports.http }} + - name: management + containerPort: {{ .Values.ports.management }} + env: + - name: SPRING_PROFILES_ACTIVE + value: ocp + # ConfigMap을 jar 안의 설정보다 우선 적용한다. + - name: SPRING_CONFIG_ADDITIONAL_LOCATION + value: file:/opt/app/config/ + - name: REDIS_HOST + value: {{ .Values.redis.host | quote }} + - name: REDIS_PORT + value: {{ .Values.redis.port | quote }} + - name: MANAGEMENT_SERVER_PORT + value: {{ .Values.ports.management | quote }} + volumeMounts: + - name: config + mountPath: /opt/app/config + readOnly: true + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: management + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: management + initialDelaySeconds: 20 + periodSeconds: 20 + resources: + {{- toYaml .Values.resources | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + volumes: + - name: config + configMap: + name: {{ include "mcp-server.name" . }}-config diff --git a/deploy/helm/mcp-server/templates/networkpolicy.yaml b/deploy/helm/mcp-server/templates/networkpolicy.yaml new file mode 100644 index 0000000..fb8ce3d --- /dev/null +++ b/deploy/helm/mcp-server/templates/networkpolicy.yaml @@ -0,0 +1,42 @@ +# 이 서버는 인증·인가를 하지 않는다(ADR-0006). 호출자를 제한하는 것은 이 정책이며, +# 이것이 빠지면 클러스터 안의 어떤 Pod이든 /mcp로 Tool을 실행할 수 있다. +# 선택적 강화가 아니라 ADR-0006의 성립 조건이므로 비활성화 스위치를 두지 않는다. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "mcp-server.name" . }}-ingress + labels: + {{- include "mcp-server.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "mcp-server.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + ingress: + # 공개 Route를 거친 요청은 OpenShift Ingress Controller에서 들어온다. + - from: + - namespaceSelector: + matchLabels: + policy-group.network.openshift.io/ingress: "" + ports: + - protocol: TCP + port: {{ .Values.ports.http }} + # 업무 포트는 Agent Builder namespace에서만 받는다. + # namespaceSelector가 참조하는 label은 대상 namespace에 실제로 붙어 있어야 한다. + # OpenShift가 자동으로 넣어 주는 kubernetes.io/metadata.name을 사용한다. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.global.agentBuilderNamespace }} + ports: + - protocol: TCP + port: {{ .Values.ports.http }} + # management 포트는 관제만 접근한다. 외부로 노출하지 않는다. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.global.monitoringNamespace }} + ports: + - protocol: TCP + port: {{ .Values.ports.management }} diff --git a/deploy/helm/mcp-server/templates/poddisruptionbudget.yaml b/deploy/helm/mcp-server/templates/poddisruptionbudget.yaml new file mode 100644 index 0000000..ebd9de8 --- /dev/null +++ b/deploy/helm/mcp-server/templates/poddisruptionbudget.yaml @@ -0,0 +1,24 @@ +{{- include "mcp-server.validate" . }} +{{- $tier := index .Values.tiers (index .Values.deployments .Values.deploymentKey).tier }} +{{- if $tier.podDisruptionBudget }} +# 중요 등급 배포가 자발적 중단(노드 drain, 클러스터 업그레이드) 중에도 최소 1개를 남기게 한다. +# +# replica를 2 이상으로 올려도 PDB가 없으면 노드 drain이 두 Pod을 한꺼번에 내릴 수 있다. +# 등급을 나눈 목적이 "중요 Tool은 다운이 없어야 한다"이므로 이 둘은 함께 가야 한다(ADR-0007). +# +# NetworkPolicy와 달리 조건이 붙는다. 저쪽은 인가의 전제라 끌 수 없지만 이것은 가용성 정책이고, +# replica 1인 dev에서는 PDB가 오히려 노드 drain을 영구히 막는다. +# 조건이 거짓이면 이 파일은 주석까지 포함해 아무것도 렌더링하지 않는다. +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "mcp-server.name" . }} + labels: + {{- include "mcp-server.labels" . | nindent 4 }} +spec: + # minAvailable을 replica 수와 같게 두면 drain이 영원히 막힌다. 1을 남기는 것으로 충분하다. + minAvailable: 1 + selector: + matchLabels: + {{- include "mcp-server.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/deploy/helm/mcp-server/templates/route.yaml b/deploy/helm/mcp-server/templates/route.yaml new file mode 100644 index 0000000..b276b1f --- /dev/null +++ b/deploy/helm/mcp-server/templates/route.yaml @@ -0,0 +1,24 @@ +{{- include "mcp-server.validate" . }} +{{- $deployment := index .Values.deployments .Values.deploymentKey }} +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: {{ include "mcp-server.name" . }} + labels: + {{- include "mcp-server.labels" . | nindent 4 }} + annotations: + haproxy.router.openshift.io/timeout: {{ .Values.route.timeout }} + haproxy.router.openshift.io/ip_allowlist: {{ .Values.route.sourceAllowlist | quote }} +spec: + host: {{ .Values.global.mcpHost | quote }} + path: {{ $deployment.publicPath | quote }} + to: + kind: Service + name: {{ include "mcp-server.name" . }} + weight: 100 + port: + targetPort: http + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect + wildcardPolicy: None diff --git a/deploy/helm/mcp-server/templates/service.yaml b/deploy/helm/mcp-server/templates/service.yaml new file mode 100644 index 0000000..78a47ed --- /dev/null +++ b/deploy/helm/mcp-server/templates/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "mcp-server.name" . }} + labels: + {{- include "mcp-server.labels" . | nindent 4 }} +spec: + selector: + {{- include "mcp-server.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.ports.http }} + targetPort: http diff --git a/deploy/helm/mcp-server/values-dev.yaml b/deploy/helm/mcp-server/values-dev.yaml new file mode 100644 index 0000000..e3ccc49 --- /dev/null +++ b/deploy/helm/mcp-server/values-dev.yaml @@ -0,0 +1,27 @@ +# dev 환경. 배포마다 Pod 1개로 구성한다. +# +# dev에서는 중요 등급도 replica 1이다. rolling update 중 수십 초 공백이 생기지만 +# dev는 가용성 목표 대상이 아니다. 중요 등급의 replica 하한과 PDB는 prod에서만 강제하며 +# HelmDeploymentContractTest가 그 사실을 고정한다. +# +# 어느 배포를 설치할지는 이 파일이 정하지 않는다. --set deploymentKey=로 고른다. +# TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다. + +global: + env: dev + agentBuilderNamespace: ax-hub-agentbuilder-dev + mcpHost: mcp-dev.apps.example.internal + +route: + # TODO: Agent Builder의 실제 고정 egress CIDR로 교체한다. + sourceAllowlist: 192.0.2.0/24 + +tiers: + critical: + replicas: 1 + podDisruptionBudget: false + spreadAcrossNodes: false + standard: + replicas: 1 + podDisruptionBudget: false + spreadAcrossNodes: false diff --git a/deploy/helm/mcp-server/values-prod.yaml b/deploy/helm/mcp-server/values-prod.yaml new file mode 100644 index 0000000..33d3197 --- /dev/null +++ b/deploy/helm/mcp-server/values-prod.yaml @@ -0,0 +1,41 @@ +# prod 환경. +# +# replica는 배포 하나가 받는 트래픽 기준으로 잡는다. 업무 × 등급으로 나뉘어 있으므로 +# 배포 하나가 받는 몫은 전체를 하나로 묶었을 때의 일부다. 등급별 기준은 아래가 정본이다. +# +# 조회 부하 = replica 수 / 주기. 1:1이라 bundle 수는 항상 1이다(ADR-0007). +# 중요 등급 3 replica / 30초 = 배포당 초당 0.1회. Tool Service 한 대가 받는 몫이 그대로 이 값이다. +# +# 중요 등급은 replica 2 이상과 PodDisruptionBudget이 필수다. +# 1이면 rolling update 중 반드시 공백이 생기고, PDB가 없으면 노드 drain이 마지막 Pod을 내린다. +# HelmDeploymentContractTest가 replica·PDB·노드 분산 values를 정적으로 검사한다. +# +# 어느 배포를 설치할지는 이 파일이 정하지 않는다. --set deploymentKey=로 고른다. +# TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다. + +global: + env: prod + agentBuilderNamespace: ax-hub-agentbuilder-prod + mcpHost: mcp.apps.example.internal + +route: + # TODO: Agent Builder의 실제 고정 egress CIDR로 교체한다. + sourceAllowlist: 192.0.2.0/24 + +tiers: + critical: + replicas: 3 + podDisruptionBudget: true + spreadAcrossNodes: true + standard: + replicas: 2 + podDisruptionBudget: false + spreadAcrossNodes: false + +resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: "2" + memory: 2Gi diff --git a/deploy/helm/mcp-server/values-test.yaml b/deploy/helm/mcp-server/values-test.yaml new file mode 100644 index 0000000..648cb17 --- /dev/null +++ b/deploy/helm/mcp-server/values-test.yaml @@ -0,0 +1,26 @@ +# test 환경. 운영계에 앞서 중요 등급의 가용성 설정을 검증하는 단계다. +# +# 중요 등급을 prod와 같은 방식(replica 2 + PDB)으로 먼저 검증하는 자리다. +# 여기서 확인하지 않으면 prod 배포 때 처음 겪게 된다. +# +# 어느 배포를 설치할지는 이 파일이 정하지 않는다. --set deploymentKey=로 고른다. +# TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다. + +global: + env: test + agentBuilderNamespace: ax-hub-agentbuilder-test + mcpHost: mcp-test.apps.example.internal + +route: + # TODO: Agent Builder의 실제 고정 egress CIDR로 교체한다. + sourceAllowlist: 192.0.2.0/24 + +tiers: + critical: + replicas: 2 + podDisruptionBudget: true + spreadAcrossNodes: true + standard: + replicas: 1 + podDisruptionBudget: false + spreadAcrossNodes: false diff --git a/deploy/helm/mcp-server/values.yaml b/deploy/helm/mcp-server/values.yaml new file mode 100644 index 0000000..671b8d5 --- /dev/null +++ b/deploy/helm/mcp-server/values.yaml @@ -0,0 +1,157 @@ +# 환경 공통 기본값과 배포 토폴로지. 환경별 차이는 values-{env}.yaml이 덮어쓴다. +# +# 이 Chart의 설계 원칙: +# 1. MCP 배포 하나는 Tool Service 하나만 본다(ADR-0007). +# bundle 목록은 항상 한 항목이며 template이 만든다. +# 2. 배포 대상 전체를 아래 deployments 한 곳에 적는다. +# 설치할 때 --set deploymentKey=로 하나를 고른다. +# 배포가 10개든 20개든 파일 수가 늘지 않고, 전체 매핑을 한 화면에서 검토할 수 있다. +# 3. 환경 축(namespace·이미지·등급별 replica)과 배포 축(어느 Tool Service를 보는가)을 섞지 않는다. +# values-{env}.yaml에는 deployments가 없고, deployments에는 환경 정보가 없다. +# 4. identity는 "{배포 이름}-{global.env}"로 조립한다. +# Redis key namespace이므로 환경끼리 겹치면 서로 Tool snapshot을 덮어쓴다. +# 사람이 손으로 적지 않게 해 실수를 구조적으로 막는다. +# 5. 외부에서는 환경별 한 host 아래 publicPath로 구분한다. Route는 Service만 선택하고 +# 컨테이너가 같은 path를 직접 처리하므로 Registry의 1:1 경계는 바뀌지 않는다(ADR-0009). + +# 설치할 배포를 고르는 key. 반드시 --set으로 지정한다. +# 기본값을 두지 않는 이유는, 지정을 빠뜨렸을 때 엉뚱한 배포가 조용히 설치되는 것보다 +# 렌더링 실패가 낫기 때문이다. +deploymentKey: "" + +global: + # 배포 환경. identity 접미사와 NetworkPolicy 판단에 쓰인다. + env: dev + # Agent Builder가 있는 namespace. Route를 우회한 Pod 직접 호출을 이 namespace로 제한한다. + # MCP와 Tool Service는 같은 namespace이므로 여기 적지 않는다. + # TODO: 실제 namespace 확정 시 교체한다. + agentBuilderNamespace: ax-hub-agentbuilder-dev + # Actuator management 포트에 접근할 관제 namespace. + monitoringNamespace: openshift-monitoring + # 환경별 공개 MCP host. 실제 OpenShift apps domain으로 교체한다. + mcpHost: mcp-dev.apps.example.internal + +# 배포 대상 전체. map의 key가 곧 bundle id가 된다. +# +# name Deployment/Service/ConfigMap/NetworkPolicy 이름. 같은 namespace에서 유일해야 한다 +# service 이 MCP가 보는 Tool Service의 Kubernetes Service 이름. 주소는 template이 조립한다 +# namePrefix 이 Tool Service가 쓰는 Tool 이름 접두사. 업무 단위이며 등급을 넣지 않는다 +# tier 가용성 등급. 아래 tiers의 key여야 한다 +# publicPath Agent Builder가 등록할 외부 MCP path. 전체 topology에서 유일해야 한다 +# +# 같은 업무의 두 등급이 같은 namePrefix를 공유하는 것은 의도된 구성이다(ADR-0007). +# 등급을 이름에 넣으면 Tool 재분류가 Tool name 변경이 되어 Agent Builder 재등록을 부른다. +# 그 안에서 Tool 이름이 겹치지 않게 하는 것은 Tool Service 책임이다. +# +# TODO: Tool 목록이 확정되면 실제 Tool Service 이름으로 교체하고, 없는 배포는 삭제한다. +deployments: + processing-critical: + name: processing-critical-mcp + service: processing-critical-tools + namePrefix: "processing." + tier: critical + publicPath: /mcp/processing-critical + processing-standard: + name: processing-standard-mcp + service: processing-standard-tools + namePrefix: "processing." + tier: standard + publicPath: /mcp/processing-standard + swring-critical: + name: swring-critical-mcp + service: swring-critical-tools + namePrefix: "swring." + tier: critical + publicPath: /mcp/swring-critical + swring-standard: + name: swring-standard-mcp + service: swring-standard-tools + namePrefix: "swring." + tier: standard + publicPath: /mcp/swring-standard + information-critical: + name: information-critical-mcp + service: information-critical-tools + namePrefix: "information." + tier: critical + publicPath: /mcp/information-critical + information-standard: + name: information-standard-mcp + service: information-standard-tools + namePrefix: "information." + tier: standard + publicPath: /mcp/information-standard + hr-critical: + name: hr-critical-mcp + service: hr-critical-tools + namePrefix: "hr." + tier: critical + publicPath: /mcp/hr-critical + hr-standard: + name: hr-standard-mcp + service: hr-standard-tools + namePrefix: "hr." + tier: standard + publicPath: /mcp/hr-standard + +# OpenShift Router와 MCP 컨테이너가 같은 publicPath를 사용한다. rewrite하지 않는다. +route: + # Agent Builder 최대 대기 시간과 맞춘 공개 HTTP 연결 timeout이다. + timeout: 300s + # 문서용 TEST-NET이다. 실제 환경에서는 Agent Builder의 고정 egress CIDR로 교체한다. + sourceAllowlist: 192.0.2.0/24 + +# 등급별 가용성 기준. 환경별 values가 덮어쓴다. +# +# 배포를 등급으로 나누는 목적이 여기에 있다. 나뉘어 있어야 중요 등급에만 비용을 쓸 수 있다. +# 다만 나누는 것만으로 가용성이 생기지는 않는다. 같은 노드 배치, namespace 쿼터, +# 공통 Redis·클러스터 장애는 분할로 막히지 않는다(ADR-0007). +tiers: + critical: + replicas: 2 + # 배포·노드 drain 중에도 최소 1개를 남긴다. + podDisruptionBudget: true + # replica를 서로 다른 노드에 두려고 시도한다. 노드가 부족하면 그대로 배치한다. + spreadAcrossNodes: true + standard: + replicas: 1 + podDisruptionBudget: false + spreadAcrossNodes: false + +image: + # TODO: 사내 컨테이너 registry 경로 확정 시 교체한다. + repository: image-registry.openshift-image-registry.svc:5000/ax-hub/ax-hub-mcp-server + tag: "0.1.0" + pullPolicy: IfNotPresent + +mcp: + # Tool Service 매니페스트 조회 주기(초). + # 1:1이라 bundle 수가 항상 1이므로 조회 부하는 (replica 수 / 주기)다. + refreshIntervalSeconds: 30 + refreshJitterSeconds: 5 + +toolService: + # MCP와 같은 namespace에 있으므로 서비스 이름 + 아래 값으로 주소가 완성된다. + port: 8080 + manifestPath: /tool-manifest + basePath: /mcp + +redis: + host: redis + port: 6379 + +ports: + http: 8080 + management: 9090 + +resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi + +# 진행 중인 tools/call은 Tool timeout 상한(30초) + 응답 쓰기만큼 걸릴 수 있다. +# Spring drain(40초)보다 길어야 drain이 끝나기 전에 Pod이 죽지 않는다. +terminationGracePeriodSeconds: 45 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..86527cb --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,216 @@ +# AX HUB MCP Server 설계 설명 + +## 책임 경계 + +```text +Agent Builder (판단, Tool 선택) + -> JSON-RPC 2.0 / Streamable HTTP +OpenShift Route (공유 host, 배포별 public path로 Service 선택, rewrite 없음) + -> 배포별 MCP Service +MCP HTTP Transport (transport/http: filter, controller, correlation, 오류 변환) + -> JSON-RPC Parser (envelope/method/params 정규화) + -> Handler Registry (method dispatch) + -> Execute Layer (metadata, validation, routing, flow control) + -> ToolClient (REST, timeout, header propagation) +Tool Service (Business Rule, Legacy/MCI/EAI 연계) +``` + +MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. Tool 추천, 사용자 의도 해석, 업무 데이터 재가공, LLM reasoning은 이 경계 밖이다. + +**인증과 인가도 이 경계 밖이다**([ADR-0006](decisions/ADR-0006-no-authentication-in-mcp.md)). 외부 호출자 제한은 Route IP allowlist가, Pod 직접 접근 제한은 NetworkPolicy가, 사용자 인증과 Tool 권한은 Agent Builder가, 사원 식별자 복호화와 업무 권한은 Tool Service가 담당한다. MCP는 요청 형식과 `inputSchema`만 검증한다. + +## 전체 실행 흐름 + +1. Agent Builder가 배포별 공개 URL `https://{host}{publicPath}`을 호출한다. OpenShift Route는 host와 path로 MCP Service만 선택하고 원래 path를 컨테이너에 전달한다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)). 요청 body·header·Tool 이름은 이 선택에 관여하지 않는다. +2. 컨테이너의 `mcp.endpoint-path` 전용 `McpExchangeFilter`가 header를 검증/추출하고 요청 전체 deadline을 포함한 `McpRequestContext`를 만든다. 호출자 헤더 다섯 개(`guid`, `x-request-id`, `mcp-session-id`, `employee-no`, `virtual-employee-no`)는 모두 선택값이며, 응답과 downstream Tool 호출에 그대로 전파한다. `guid`는 요청 하나의 end-to-end 상관 값, `x-request-id`는 개별 HTTP 요청 식별자다. +3. filter는 크기가 제한된 repeatable request body에서 `method`만 관찰용으로 읽고 `mcp_http_request_received` 로그를 남긴다. body, header 값, credential은 로그에 저장하지 않는다. +4. `McpProtocolVersionValidator`가 `initialize`를 제외한 요청의 `MCP-Protocol-Version`을 supported versions와 대조한다. 누락·불일치는 Controller 진입 전 HTTP 400으로 종료한다. +5. `JsonRpcRequestParser`가 envelope를 정규화하고 JSON-RPC 2.0, method, id, params shape를 최종 검증한다. + JSON-RPC version, 표준 오류 번호와 MCP method 이름은 MCP Java SDK 2.0 상수를 참조한다. +6. `McpMethodHandlerRegistry`가 method를 명시적 handler에 연결한다. +7. `tools/list`는 `ToolRegistryService`의 in-memory snapshot에서 실행 metadata를 얻는다. 요청 경로는 Redis를 호출하지 않으므로 Redis 장애·지연이 응답에 영향을 주지 않으며, snapshot이 비어 있는 기동 직후에만 Tool catalog provider를 한 번 조회한다. 이후 `ToolsListHandler`가 MCP SDK의 `Tool`과 `ListToolsResult`로 변환한다. local 기본 구성은 Tool Service 매니페스트를 먼저 조회하고, 최초 실패 시 bundle별 local manifest sample을 cold-start fallback으로 사용한다. 운영은 이 배포가 보는 Tool Service 매니페스트의 사용 가능한 성공본만 원천으로 사용한다. +8. `tools/call`은 `ToolsCallHandler`가 표준 MCP의 `params.name`과 object인 `params.arguments`를 검증하고 추출한다. +9. `ToolExecutionService`가 표준 Tool name으로 metadata를 확정하고 argument schema를 검증한다. `ToolRoutingService`는 안전한 Tool name을 설정의 base endpoint 뒤에 붙여 `POST {baseEndpoint}/{toolName}` 요청과 metadata timeout을 만든다. Agent Builder가 보낸 `arguments` 객체는 JSON raw body로 전달하며 MCP가 Tool을 대체 선택하지 않는다. +10. `arguments`의 어떤 field도 outbound URL 선택에 사용하지 않는다. endpoint는 local catalog의 `_meta.endpoint` 또는 운영 배포 설정의 `baseEndpoint`에서만 가져오므로 Agent Builder 입력이나 Tool Service 매니페스트로 outbound 대상이 바뀌지 않는다. +11. `HttpToolClient`가 JDK 공유 HTTP client의 connection pool을 사용해 correlation 헤더와 함께 POST를 실행한다. arguments는 JSON body로 전달하며 Tool read timeout은 metadata timeout과 요청 전체 deadline의 남은 시간 이하로 제한한다. Authorization 전달은 설정으로 통제한다. +12. Tool 응답은 요청 payload와 분리해 `response.data`만 사용한다. plain text는 그대로, JSON object/array는 compact JSON string으로 MCP SDK `CallToolResult`/`TextContent`의 `result.content[0].text`에 넣고 outer JSON serializer가 escaping을 처리한다. 호출 소요 시간(ms)은 `result.content[0]._meta.searchTime`으로 반환하고, 정상 결과에도 `isError: false`를 명시한다. Tool 실행·timeout·권한 오류는 JSON-RPC error가 아니라 `isError: true` result로 변환한다. JSON-RPC envelope/params/method 및 서버 구성 오류는 최상위 JSON-RPC `error`로 반환한다. +13. local과 운영 모두 같은 `name` lookup, endpoint/timeout, inputSchema validation 경로를 사용한다. +14. Agent Builder가 `Accept: application/json, text/event-stream`을 보내도 서버는 단일 `application/json` JSON-RPC response를 반환한다. filter는 status와 소요 시간을 `mcp_http_response_completed` 로그로 남기며 응답 body는 저장하지 않는다. +15. 모든 예외는 `JsonRpcException`/`McpExceptionHandler`에서 표준 error로 변환하고, filter가 ThreadLocal을 반드시 정리한다. + +## 주요 클래스별 책임 + +| 클래스 | 패키지 | 책임 | +|---|---|---| +| `McpController` | `transport/http` | `mcp.endpoint-path`의 단일 공개 endpoint, parser/handler 연결, notification 202와 initialize UUID header 선택 | +| `McpRequestContextFactory` | `transport/http` | 호출자 헤더 5종 추출. correlation 값 형식 검증, 사원 식별자는 해석하지 않고 주입 위험 문자만 차단 | +| `McpRequestContextHolder` | `context` | 요청 수명 ThreadLocal 저장; 세션 저장소가 아님 | +| `JsonRpcRequestParser` | `jsonrpc` | JSON-RPC envelope shape 검증과 내부 request 정규화 | +| `McpMethodHandlerRegistry` | `method` | `Handler` 전략과 method dispatch를 한 경계에서 관리 | +| `ToolRegistryService` | `registry` | 요청 경로(memory 전용)와 배경 갱신 경로(provider 조회 후 memory→Redis 저장) 분리, single-flight refresh, 공유 cache warm start | +| `ToolsListHandler` | `method` | 실행 metadata를 MCP 공개 Tool로 변환하고 `_meta` 제거. 공개 필드 목록은 [계약 v0.3](contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md#toolslist)이 정본 | +| `LocalFileToolRegistryClient` | `registry` | 매니페스트 조회를 끈 local profile에서 legacy JSON/manifest fixture를 읽어 테스트 Tool 목록을 제공 | +| `ToolBundleDiscovery` | `registry` | 구현상 N개 Tool Service 매니페스트를 병렬 조회·검증하고 bundle별 last-good 상태를 유지. 최초 원격 조회 실패 시에만 설정된 local manifest fallback을 사용하며, 운영 배포는 1개 Bundle만 사용 | +| `ToolBundleRegistryClient` | `registry` | 구현상 모든 bundle의 사용 가능한 성공본을 중복·총량 검증 후 하나의 snapshot으로 병합. 운영 배포에서는 단일 Bundle 결과를 채택 | +| `RedisToolRegistryCache` | `registry` | best-effort Redis snapshot, 실제 read/write 실패를 cache miss로 격리 | +| `ToolRegistryRefreshScheduler` | `registry` | 기동 preload와 주기 refresh; 실패 시 애플리케이션 생존 | +| `ToolArgumentValidator` | `execute` | 기존 required/type 오류 계약을 보존하고 MCP SDK JSON Schema 2020-12 검증 적용 | +| `ToolExecutionService` | `execute` | 이름 기반 metadata 해석, argument validation, 단일 Tool 실행, HTTP 경계 로그와 오류 mapping | +| `ToolRoutingService` | `execute` | 단일 POST endpoint와 timeout 확정, 기본 URI 검증 | +| `ToolClient` | `toolclient` | Tool 호출 port와 해당 경계의 `ToolRequest`/`ToolResponse`/실패 타입 소유 | +| `HttpToolClient` | `toolclient` | HTTP 호출, headers, timeout, JSON/text 응답 처리 | +| `ToolBundleStatusEndpoint` | `observability` | management port의 Actuator `toolBundles` 상태 조회 | +| `ToolCatalogHealthIndicator` | `observability` | readiness group 판정. 첫 조회 시도가 끝나고 usable in-memory snapshot이 있을 때만 UP | +| `McpExchangeFilter` | `transport/http` | 설정된 MCP endpoint 전처리, request 크기 제한, correlation, HTTP 요청·응답 경계 로그 | +| `McpProtocolVersionValidator` | `transport/http` | `initialize` 이후 HTTP `MCP-Protocol-Version`의 지원 여부 검증; 서버 상태를 저장하지 않음 | +| `TraceLogger` | `observability` | context의 guid/requestId를 직접 포함하는 최소 key=value 경계 로그. 사원 식별자는 기록하지 않음 | +| `McpExceptionHandler` | `transport/http` | JSON parse, JSON-RPC, 예상 밖 오류의 표준 response 변환 | + +Jackson 3 databind 모델은 `tools.jackson.databind.*`을 사용한다. 이 조합의 annotation API는 `com.fasterxml.jackson.annotation.*` namespace로 제공되므로, 이를 `tools.jackson.annotation.*`으로 바꾸지 않는다. Registry 응답의 unknown field 무시는 회귀 테스트로 검증한다. + +MCP Java SDK는 protocol 상수, 표준 result 모델과 JSON Schema validator에만 사용한다. SDK/Spring AI MCP +Starter와 transport는 활성화하지 않으며 기존 `/mcp`, 보안, correlation, Registry, Tool 실행 경계를 유지한다. +상세 도입 범위와 업그레이드 검증 기준은 [MCP Java SDK 선택적 도입 설계](mcp-java-sdk-adoption.md)를 따른다. + +## 소스 구성 원칙 + +- Spring component, 외부 adapter, 교체 가능한 port 구현은 책임별 독립 파일로 유지한다. +- 특정 서비스나 port에서만 의미가 있는 immutable record, enum, 예외는 소유 타입 안에 둔다. +- `ToolClient`가 request/response/failure 타입을, `ToolExecutionService`가 실행 result를 소유한다. +- `McpMethodHandlerRegistry`는 handler 계약을, `ToolRegistryClient`는 원천 목록 조회 port를 소유한다. +- bean이나 동작을 제공하지 않는 빈 configuration class는 두지 않는다. Spring Boot auto-configuration과 application class의 scheduling 설정을 그대로 사용한다. +- 파일 수를 줄이기 위해 서로 다른 서비스 책임을 합치지는 않는다. transport, Registry, execution, Tool client, observability 경계는 계속 분리한다. + +### 패키지 경계 + +| 패키지 | 책임 | +|---|---| +| `transport/http` | HTTP로 들어오고 나가는 경계 전부. filter, controller, 본문 wrapper, protocol version 검증, context 생성, 오류 응답 변환 | +| `context` | 요청 수명 동안 공유하는 값과 ThreadLocal holder. 전송 방식을 모른다 | +| `jsonrpc` | JSON-RPC envelope 모델과 파싱·오류 코드 | +| `method` | MCP method별 handler와 dispatch | +| `execute` / `toolclient` | 단일 Tool 실행(응용 서비스)과 outbound port·adapter | +| `registry` | Tool 목록의 원천 조회, 병합, 캐시 | +| `observability` | 경계 로그, health indicator, Actuator 상태 endpoint | + +**`jakarta.servlet` 의존은 `transport` 패키지 안에서만 허용한다.** MCP는 stdio 등 다른 transport를 가질 수 있는 +프로토콜이므로, 서블릿 타입이 이 경계 밖으로 새면 전송 방식이 응용 계층에 굳어진다. +이 규칙은 `PackageBoundaryContractTest`가 강제한다. + +## Stateless 보장 + +- `HttpSession`/Spring Session 의존성이나 API를 사용하지 않는다. +- `mcp-session-id`는 요청 context와 downstream correlation header에만 사용한다. +- initialize 응답의 `Mcp-Session-Id`는 Agent Builder가 이후 요청에 전달하는 opaque correlation 값이다. 서버는 이를 발급했는지·notification을 받았는지·session readiness를 저장하거나 검증하지 않는다. +- Tool metadata의 in-memory cache는 업무/사용자 세션 상태가 아닌 재구성 가능한 read-only snapshot이다. +- replica가 달라져도 동일 요청 계약을 수행할 수 있다. +- HTTP와 Tool 호출 경계 로그는 requestId/guid로 연결하지만 업무·세션 상태를 저장하지 않는다. + +## HTTP 경계 로그 + +- 모든 MCP method(`initialize`, `notifications/initialized`, `tools/list`, `tools/call`)는 설정된 공개 path의 POST body에 있는 JSON-RPC `method`로 식별된다. Filter는 이 값과 HTTP status, 소요 시간만 요청·응답 경계 로그에 남긴다. +- `guid`가 있으면 end-to-end 흐름 전체에 그대로 사용하고, 없으면 UUID를 생성한다. `x-request-id`도 있으면 그대로 사용하고 없으면 생성해 response header와 downstream Tool header에 전파한다. +- MDC는 사용하지 않는다. `TraceLogger`가 `McpRequestContextHolder`에서 guid와 requestId를 읽어 각 메시지에 직접 포함한다. +- Authorization/Cookie/API key, session 식별자, 요청·응답 body는 logger에 남기지 않는다. +- **`employee-no`와 `virtual-employee-no`는 암호문이라도 로그에 남기지 않는다.** 개인 식별자이며, 암호화는 저장·전송 보호이지 로그 기록 허가가 아니다. +- 수신 request body는 `mcp.trace.max-body-bytes`로 제한한다. 제한을 넘으면 Controller에 전달하지 않고 JSON-RPC `Invalid Request`로 종료한다. 이 설정은 로그 capture 크기가 아니라 입력 경계 보호 정책이다. +- 현재 구현은 애플리케이션 로그만 제공하며 불변 감사 저장소가 아니다. 보존·위변조 방지·재처리가 필요한 규제 감사 요건이 확정되면 그때 별도 durable sink를 설계한다. +- 응답을 쓰는 중 `IOException`이 나면 `mcp_http_response_undeliverable` event로 남긴다. 대개 Agent Builder가 먼저 연결을 끊은 경우이며, 이 로그가 없으면 결과 유실 자체를 관측할 수 없다. + +## 요청 시간 예산 + +Agent Builder는 응답을 **300초**까지만 기다리고 연결을 끊는다. MCP의 예산은 그보다 짧아야 한다. +같거나 길면 MCP가 응답을 완성해도 받을 상대가 이미 사라진 뒤다. + +| 계층 | 값 | 근거 | +|---|---|---| +| Agent Builder 대기 한도 | 300초 | 외부 제약. Agent가 MCP를 호출한 시점부터 잰다 ([ADR-0004](decisions/ADR-0004-execution-guardrails.md)) | +| MCP 요청 전체 예산 `request-deadline-millis` | 270초 | 30초 여유. MCP 시계는 요청이 도착한 뒤 출발하므로 그만큼 더 안전하다 | +| Tool 개별 timeout 상한 `maxToolTimeoutMillis` | **30초** | 매니페스트 선언값의 상한. **실질적으로 이 값이 요청 시간을 결정한다** | + +Tool 호출 직전마다 `remainingMillis()`로 남은 예산을 계산해 read timeout을 그 이하로 깎는다. +따라서 Tool 하나가 자기 timeout을 다 써도 요청 전체 예산을 넘지 않는다. + +**270초는 실제로는 거의 도달하지 않는 backstop이다.** 한 요청은 Tool을 정확히 하나만 실행하고, +그 Tool의 timeout은 30초로 상한이 걸려 있다. 따라서 정상 경로의 최대 소요는 연결 1초 + 읽기 30초 +수준이다. 270초가 의미를 갖는 것은 `maxToolTimeoutMillis`를 크게 올릴 때뿐이며, +그때는 이 표 전체를 다시 계산해야 한다. + +이 관계 때문에 graceful shutdown 시간도 300초가 아니라 실질 상한(약 31초)에 맞춘다. +`spring.lifecycle.timeout-per-shutdown-phase`(40초) < `terminationGracePeriodSeconds`(45초) 순서를 지켜, +진행 중인 Tool 호출이 배포 중에 잘려 부작용만 남는 상황을 줄인다. + +연결이 이미 끊긴 뒤 Tool 결과가 도착하는 경우는 **완전히 막을 수 없다.** MCP는 결과를 저장했다가 +나중에 전달하지 않는다(stateless, [ADR-0001](decisions/ADR-0001-stateless-execution-boundary.md)). +중복 실행 방지는 Tool Service의 책임이다. retry에서 같은 `guid`를 재사용해 멱등성 키로 삼을지는 +[미합의 항목](extension-points.md)이며, 합의 전에는 MCP가 이를 보장한다고 가정하지 않는다. + +## Protocol version 협상과 검증 + +- 서버는 `mcp.protocol.supported-versions`와 `mcp.protocol.preferred-version`으로 지원 버전을 명시적으로 관리한다. preferred version은 반드시 supported versions에 포함되어야 한다. +- `initialize` 응답은 요청의 JSON-RPC `id`를 그대로 반환하며, preferred version과 `serverInfo(name/title/version)`, `capabilities.tools.listChanged=false`를 제공한다. +- 이 서버는 stateless이므로 협상 결과를 session에 저장하지 않는다. `initialize` 이후 Agent Builder는 모든 MCP HTTP 요청에 `MCP-Protocol-Version: `을 포함해야 하며, 서버는 매 요청을 독립적으로 검증한다. +- header가 누락되거나 지원하지 않는 값이면 JSON-RPC error가 아닌 HTTP `400 Bad Request`를 반환한다. 오류 body는 `error`, `message`, `supportedVersions`, `guid`를 포함해 호출자가 올바른 header를 진단할 수 있게 한다. + +## Initialize lifecycle correlation + +- `initialize`의 JSON-RPC result는 server protocol/capability 정보를 제공하고, HTTP response header `Mcp-Session-Id`에는 새 UUID를 제공한다. +- Agent Builder는 이 값을 `notifications/initialized`, `tools/list`, `tools/call`의 `Mcp-Session-Id` header에 보낸다. 각 HTTP 요청은 별도 `x-request-id`를 유지한다. +- Agent Builder는 MCP 2025-06-18 lifecycle에 따라 `notifications/initialized`를 보낸다. 서버는 이를 저장하거나 이후 요청의 readiness gate로 사용하지 않는다. +- `InitializedNotificationHandler`는 id 없는 notification을 HTTP 202으로 수용한다. 이는 Tool 실행 준비 상태를 메모리에 세우는 동작이 아니므로 replica 간 affinity가 필요 없다. + +## Tool metadata 갱신 장애 시나리오 + +요청 경로는 memory만 읽으므로 Redis 상태가 등장하지 않는다. + +| Memory | Tool Service aggregate | 결과 | +|---|---|---| +| hit | 무관 | memory 반환. **Redis를 호출하지 않는다** | +| miss (기동 직후) | 확정 가능 | provider 직접 조회 후 memory 저장 | +| miss (기동 직후) | 확정 불가 | Redis에 다른 replica의 snapshot이 있으면 채택, 없으면 `-32003 Tool registry unavailable` | + +readiness는 첫 discovery 시도 완료와 usable in-memory snapshot을 모두 요구한다. 원천 조회가 실패해도 기존 +memory 또는 Redis last-good을 채택했다면 UP이며, 둘 다 없어 `-32003`만 반환할 상태라면 DOWN이다. 따라서 +rolling update 중 새 Pod이 빈 catalog로 기존 정상 Pod을 대체하지 않는다. 정상 매니페스트가 빈 Tool 목록을 +반환한 경우에는 그 빈 목록도 성공적으로 확정된 전체 상태이므로 usable snapshot이다. + +배경 갱신 경로의 동작은 다음과 같다. + +| Tool Service aggregate | 기존 memory | Redis | 결과 | +|---|---|---|---| +| 확정 가능 | 무관 | 무관 | memory 갱신 후 Redis 저장(best-effort). **성공한 결과만 저장한다** | +| 확정 불가 | hit | 무관 | 현재 memory 유지. 더 오래된 Redis 값으로 덮어쓰지 않는다 | +| 확정 불가 | miss | hit | Redis의 공유 last-good snapshot으로 warm start | +| 확정 불가 | miss | miss/장애 | `-32003`을 반환하고 다음 주기에 재시도 | + +각 bundle은 이번 성공본 또는 직전 성공본이 있어야 aggregate를 확정할 수 있다. 조회 실패는 Tool 삭제로 해석하지 않으며, 성공한 매니페스트에서 빠진 경우에만 삭제를 반영한다. 이름 충돌이나 총량 상한 초과도 전체 갱신 실패로 처리한다. 동시에 여러 refresh가 들어오면 single-flight로 하나의 원천 조회 결과를 공유한다. + +캐시에서 Tool을 찾지 못하면 stale snapshot 가능성을 고려해 원천을 한 번 더 조회한 뒤 `-32001`을 결정한다. +Redis는 요청 경로의 의존성이 아닌 선택적인 warm-start cache다. key 형식, TTL, 공유 범위, 고가용성·보안 정책은 +아직 확정하지 않았으며 [extension-points.md](extension-points.md#운영-적용-전-필수-보완)에서 합의한다. 현재 구현값은 +운영 계약이나 장기 설계 결정이 아니다. + +## Local Tool manifest fallback + +로컬 Agent Builder 연동 검증도 실제 Tool Service와 같은 매니페스트 조회 흐름을 먼저 사용한다. `application-local.yml`의 bundle URL을 조회하고, **처음 조회가 실패했을 때만** `fallback-manifest-file`의 manifest sample을 snapshot으로 채택한다. 기본 sample은 프로젝트 루트의 `config/local-core-tools-manifest-sample-v1.json`이다. 원격 조회가 이후 성공하면 즉시 원격 목록으로 교체하며, 이미 확보한 원격 성공본은 local sample로 덮어쓰지 않는다. + +노출 대상 Tool은 그 파일이 정의한다. 목록을 이 문서에 옮겨 적지 않는다. 파일의 공개 필드는 그대로 보존하고 `_meta` 실행 정보만 제거해 `tools/list`에 내보낸다. fallback도 원격 매니페스트와 같이 설정된 `base-endpoint`에 요청 name을 path segment로 붙여 `tools/call`을 POST한다. + +이 fixture는 연동 확인용이며 실제 고객·계약·수납·지급 데이터를 담지 않는다. + +운영 profile에서는 `ToolBundleDiscovery`와 `ToolBundleRegistryClient`만 metadata 원천으로 활성화한다. MCP 배포별 `mcp.bundles`가 Tool Service의 매니페스트와 실행 주소를 선언한다. 운영 Helm 설정에는 fallback 파일을 넣지 않는다. Tool Service는 표준 `name`을 소유하고, MCP는 자기 Bundle 안에서 형식·설정된 `namePrefix`·중복을 검증하되 이름을 재작성하지 않는다. 서로 다른 MCP 배포 간 이름의 전역 유일성은 Tool Service·플랫폼의 변경 절차가 보장한다. Redis는 선택적인 공유 last-good cache일 뿐 Tool 목록의 원천이 아니다. + +**`mcp.bundles`는 N개를 지원하지만 운영 배포에서는 항상 한 항목이다.** MCP 배포 하나가 Tool Service 하나만 보기로 했기 때문이다([ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md)). 대상을 늘리는 방법은 이 목록을 늘리는 것이 아니라 MCP 배포를 하나 더 만드는 것이다. 그래야 등급이 다른 Tool Service의 조회 실패가 서로의 카탈로그 갱신을 막지 않는다. 다중 bundle 병합 코드는 유지하되 Helm Chart가 1개로 잠그고 `HelmDeploymentContractTest`가 그 사실을 검사한다. + +각 배포는 같은 환경 host의 고유 `publicPath`를 가진 OpenShift Route로 노출된다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)). Route는 path로 Service만 선택하고 컨테이너가 같은 값을 `mcp.endpoint-path`로 직접 처리한다. Java 애플리케이션에는 route table이나 다중 Registry를 추가하지 않는다. Deployment·snapshot·readiness·connection pool은 path별로 분리되고, 공유되는 장애 지점은 OpenShift ingress와 DNS다. + +운영 상태는 외부 ingress가 아니라 management port(기본 9090)의 `GET /actuator/toolBundles`로 확인한다. + +## 변경 시 검증 경계 + +- MCP envelope/method 변경: adapter → handler registry → handler 직렬화 테스트 +- `tools/call` 변경: handler params → Registry metadata → argument validator → routing → Tool client → error mapping +- Tool metadata 변경: 매니페스트 역직렬화·bundle 검증 → aggregate 확정 → memory/Redis fallback → refresh scheduler +- correlation 변경: header extractor → filter/context 정리 → response/downstream header +- 공개 path/배포 변경: topology의 path 유일성 → Route host/path/Service → ConfigMap endpoint → Controller·Filter → Agent Builder 등록 URL +- 최종 확인: `.\gradlew.bat clean check`, `bootJar`, 실행 JAR의 initialize → notification → tools/list 흐름 diff --git a/docs/codex-workflow.md b/docs/codex-workflow.md new file mode 100644 index 0000000..90ee940 --- /dev/null +++ b/docs/codex-workflow.md @@ -0,0 +1,60 @@ +# Codex 활용 및 저장소 공개 정책 + +## 목적과 책임 + +| 항목 | 소비자 | 저장소 포함 | +|---|---|---:| +| `AGENTS.md` | Codex의 모든 작업 | 예 | +| `.agents/skills/` | 조건에 맞는 Codex 작업 | 예 | +| `.codex/config.toml` | 신뢰된 프로젝트에서의 Codex 실행 환경 | 기본 아니오 | +| `docs/` | 개발자와 리뷰어 | 예 | +| `src/` | 애플리케이션 빌드와 테스트 | 예 | +| `deploy/` | 배포 플랫폼 | Secret이 없을 때만 예 | +| `samples/` | 개발자와 테스트 | 가짜 데이터일 때만 예 | + +루트의 `AGENTS.md`, `.agents/`, `.codex/`는 Codex의 표준 탐색 지점이다. 보기 좋게 다른 하위 폴더로 옮기지 않는다. 사람을 위한 설명은 이 문서와 `README.md`에 둔다. + +## 일상 작업 흐름 + +1. 일반 작업은 루트 `AGENTS.md` 규칙을 따른다. +2. JSON-RPC, MCP method, Tool Registry, Tool 실행, trace log, Spring profile, OpenShift 배포를 변경할 때는 `$verify-mcp-server-change`를 명시적으로 호출한다. +3. Skill은 코드와 관련 테스트를 읽고 영향 계약을 확인한 뒤 최소 변경과 Gradle 테스트를 요구한다. +4. 반복 작업이 세 번 이상 안정적으로 반복되면 새 skill 후보로 검토한다. 임시 작업, 일회성 지침, 개인 메모는 skill로 만들지 않는다. +5. 작업 중 발견한 규칙은 재현·검토된 뒤에만 `AGENTS.md`, skill reference, 또는 설계 문서에 반영한다. + +예시: + +```text +$verify-mcp-server-change +tools/call의 인자 검증을 변경하고 관련 테스트와 문서를 갱신해 줘. +``` + +## Codex 설정 정책 + +개인 전역 Codex 설정에는 선호 모델, approval policy, sandbox mode, 개인 MCP 서버, 로컬 경로, 인증 정보를 둔다. 이 내용은 저장소에 올리지 않는다. + +프로젝트 `.codex/config.toml`은 다음 조건을 모두 충족할 때만 추가하고 Git에 포함한다. + +- 모든 팀원이 동일하게 적용해야 한다. +- 프로젝트 고유의 설정이다. +- 비밀정보, 개인 경로, 개인 권한 선호, 개인 MCP 연결이 없다. +- 일반 코드 변경처럼 리뷰할 수 있다. + +현재 이 프로젝트는 위 조건에 해당하는 공유 Codex 실행 설정이 없으므로 `.codex/config.toml`을 사용하지 않는다. 프로젝트 설정이 필요한 시점에는 팀 검토 후 `.gitignore`의 해당 예외 규칙을 함께 변경한다. + +## GitHub 게시 전 점검 + +1. 기본 공개 범위는 private로 한다. 공개 전환은 조직 정책, 내부 명칭, 배포 정보, 계약 문서, 샘플 데이터 검토 후 별도 결정한다. +2. `AGENTS.md`, `.agents/skills/`, 소스, 테스트, 가짜 샘플, 비밀정보 없는 배포 명세와 문서는 커밋한다. +3. `.env`, 인증서, keystore, Secret manifest, 실제 고객·운영 데이터, 개인 Codex 설정은 커밋하지 않는다. +4. 첫 push 전에 Secret 검색을 실행하고, GitHub에서는 secret scanning, push protection, Dependabot, 기본 브랜치 보호를 활성화한다. +5. 외부 공개가 확정되면 `LICENSE`, `SECURITY.md`, `CONTRIBUTING.md`와 공개용 샘플·문서를 추가한다. + +## 구조 확장 기준 + +- `docs/decisions/`: 장기 설계 결정이 실제로 발생할 때 ADR을 추가한다. +- `src//AGENTS.md`: 특정 하위 모듈의 명령이나 규칙이 루트와 달라질 때만 추가한다. +- `.agents/skills//`: 반복되고 안정적인 작업 절차가 생길 때만 추가한다. +- `.codex/hooks/`: 지침과 테스트로 보장할 수 없는 결정적 정책을 강제해야 할 때만 추가한다. + +빈 폴더나 자동 누적 `lessons.md`는 만들지 않는다. diff --git a/docs/contracts/agent-builder-mcp/README.md b/docs/contracts/agent-builder-mcp/README.md new file mode 100644 index 0000000..6e6ac14 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/README.md @@ -0,0 +1,16 @@ +# Agent Builder-MCP 계약 문서 + +이 디렉터리는 현재 구현 계약과 목표 합의 기준선을 분리해 관리한다. + +| 문서 | 상태 | 용도 | +|---|---|---| +| [protocol-v0.3-streaming-policy.md](protocol-v0.3-streaming-policy.md) | Implemented | `text/event-stream` Accept를 수용하는 동기 JSON 현재 계약 | +| [protocol-v0.2-agentbuilder.md](protocol-v0.2-agentbuilder.md) | Superseded | streaming 정책 도입 전 non-streaming 계약 | +| [protocol-v1-agreement-baseline.md](protocol-v1-agreement-baseline.md) | Partial Agreement | 2026-07-10 협의에서 확정된 목표 원칙만 기록 | + +`protocol-v1-agreement-baseline.md`는 아직 실행 가능한 전체 wire contract가 아니다. Agent Builder의 전체 JSON 샘플과 미확정 항목이 승인되기 전까지 현재 구현을 변경하는 직접 근거로 사용하지 않는다. + +현재 구현 예시는 [examples/agentbuilder-v0.3](examples/agentbuilder-v0.3/)에서 관리한다. `initialize` 이후 요청에는 `MCP-Protocol-Version` HTTP header가 필요하다. +현재 공개 주소는 환경별 host와 배포별 path를 합친 `https://{mcpHost}{publicPath}`이며, 각 URL을 독립 MCP로 등록한다. 컨테이너가 공개 path를 직접 처리하는 기준은 [ADR-0009](../../decisions/ADR-0009-container-handles-public-mcp-path.md)이 정본이다. + +Agent Builder가 실제 Tool을 호출하며 남긴 관찰용 JSON-RPC 로그는 [observed-samples/2026-07-13](observed-samples/2026-07-13/)에 분리해 보관한다. 이 로그는 구현 계약이나 자동화 테스트 fixture가 아니다. diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-request.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-request.json new file mode 100644 index 0000000..2f88d03 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-request.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { + "name": "toolbox-executor", + "version": "0.1.0" + } + } +} diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-response.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-response.json new file mode 100644 index 0000000..2e92693 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-response.json @@ -0,0 +1,9 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2025-06-18", + "serverInfo": {}, + "capabilities": {} + } +} diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialized-notification.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialized-notification.json new file mode 100644 index 0000000..f3936d5 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialized-notification.json @@ -0,0 +1,5 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} +} diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/tools-call-execution-error-response.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/tools-call-execution-error-response.json new file mode 100644 index 0000000..9d8d772 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/tools-call-execution-error-response.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "content": [ + { + "type": "text", + "text": "customer.search@1.0.0: timed out" + } + ], + "isError": true + } +} diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/tools-call-request.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/tools-call-request.json new file mode 100644 index 0000000..3390f67 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/tools-call-request.json @@ -0,0 +1,11 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "customer.search", + "arguments": { + "customerNo": "1234567890" + } + } +} diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/tools-call-success-response.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/tools-call-success-response.json new file mode 100644 index 0000000..5923637 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/tools-call-success-response.json @@ -0,0 +1,16 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "content": [ + { + "type": "text", + "text": "{\"customerName\":\"Hong\"}" + } + ], + "structuredContent": { + "customerName": "Hong" + }, + "isError": false + } +} diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/initialize-response.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/initialize-response.json new file mode 100644 index 0000000..0a7c70f --- /dev/null +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/initialize-response.json @@ -0,0 +1,17 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { + "tools": { + "listChanged": false + } + }, + "serverInfo": { + "name": "shl-axhub-mcp-server", + "title": "SHL AX HUB MCP Server", + "version": "1.0.0" + } + } +} diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-execution-error-response.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-execution-error-response.json new file mode 100644 index 0000000..9d8d772 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-execution-error-response.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "content": [ + { + "type": "text", + "text": "customer.search@1.0.0: timed out" + } + ], + "isError": true + } +} diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-invalid-params-response.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-invalid-params-response.json new file mode 100644 index 0000000..5cea45a --- /dev/null +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-invalid-params-response.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "error": { + "code": -32602, + "message": "Invalid params: 'query' is required" + } +} diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-request.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-request.json new file mode 100644 index 0000000..e08ffce --- /dev/null +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-request.json @@ -0,0 +1,12 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "processing", + "arguments": { + "query": "processing system information inquiry", + "category": "processing" + } + } +} diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-success-response.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-success-response.json new file mode 100644 index 0000000..4f44eec --- /dev/null +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-success-response.json @@ -0,0 +1,16 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "content": [ + { + "type": "text", + "text": "processing complete", + "_meta": { + "searchTime": 976.1 + } + } + ], + "isError": false + } +} diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-list-response.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-list-response.json new file mode 100644 index 0000000..a53eaa9 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-list-response.json @@ -0,0 +1,67 @@ +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + { + "name": "insurance.processing.test", + "title": "처리계 연계 점검", + "description": "처리계 연계 경로와 요청·응답 형식을 점검하는 테스트 전용 도구입니다. 실제 보험 업무 데이터는 처리하지 않습니다.", + "inputSchema": { + "type": "object", + "properties": { + "requestId": { + "type": "string", + "description": "테스트 요청 식별자입니다." + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "insurance.corebanking.test", + "description": "계정계 연계 경로와 요청·응답 형식을 점검하는 테스트 전용 도구입니다. 실제 계약·수납·지급 처리는 수행하지 않습니다.", + "inputSchema": { + "type": "object", + "properties": { + "requestId": { + "type": "string", + "description": "테스트 요청 식별자입니다." + } + } + } + }, + { + "name": "insurance.information.test", + "description": "정보계 연계 경로와 요청·응답 형식을 점검하는 테스트 전용 도구입니다. 실제 고객·계약 정보를 조회하지 않습니다.", + "inputSchema": { + "type": "object", + "properties": { + "requestId": { + "type": "string", + "description": "테스트 요청 식별자입니다." + } + } + } + }, + { + "name": "insurance.channel.test", + "description": "채널계 연계 경로와 요청·응답 형식을 점검하는 테스트 전용 도구입니다. 실제 고객 채널 업무를 수행하지 않습니다.", + "inputSchema": { + "type": "object", + "properties": { + "requestId": { + "type": "string", + "description": "테스트 요청 식별자입니다." + } + } + } + } + ] + } +} diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-initialize-request.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-initialize-request.json new file mode 100644 index 0000000..15a192b --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-initialize-request.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { + "name": "toolbox-executor", + "version": "0.1.0" + } + } +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-initialize-response.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-initialize-response.json new file mode 100644 index 0000000..f15b53e --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-initialize-response.json @@ -0,0 +1,34 @@ +{ + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { + "tools": { + "listChanged": true + }, + "prompts": { + "listChanged": true + }, + "resources": { + "listChanged": true + } + }, + "serverInfo": { + "name": "exa-search-server", + "title": "Exa", + "version": "3.2.1", + "websiteUrl": "", + "icons": [ + { + "src": "", + "mimeType": "image/png", + "sizes": [ + "32x32" + ] + } + ] + } + }, + "jsonrpc": "2.0", + "id": 1 +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-initialized-notification.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-initialized-notification.json new file mode 100644 index 0000000..9ed0b56 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-initialized-notification.json @@ -0,0 +1,5 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/initialized" +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-tools-call-request.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-tools-call-request.json new file mode 100644 index 0000000..346a926 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-tools-call-request.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "web_search_exa", + "arguments": { + "query": "most popular useless github repository stars useless repo", + "numResults": 10 + } + } +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-tools-call-response.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-tools-call-response.json new file mode 100644 index 0000000..24e88b5 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316-tools-call-response.json @@ -0,0 +1,16 @@ +{ + "result": { + "content": [ + { + "type": "text", + "text": "Title: btahir/uselesshooks\nURL: N/A\nAuthor: N/A\nHighlights:\n# btahir/us\n...\n- Stars: 125\n- Forks: 9\n- Watchers: 125\n- Openissues: 3\n- License: MIT License\n- Default branch: main\n- Created: 2022-12-18T03:03:19Z\n\n---\n\nTitle: Useless-Garbage-Institute/useless-garbage\nURL: 2015-08-27T22:14:06.000Z\nAuthor: N/A\nHighlights:\n# Repository: Useless-Garbage-Institute/useless-garbage\n...\nThe most profoundly useless javascript library ever invented.\n...\n- Stars: 6\n- Forks: 1\n- Watchers: 6\n- Open issues: 1\n- Primary language: JavaScript\n- Languages: JavaScript\n- Default branch: master\n- Created: 2015-08-27T22:14:06Z\n- Last push: 2015-09-01T01:14:37Z\n- Contributors: 2 (top: kriztynna, cgalbiati)\n...\nThe useless-garbage library is theonly npm module that is guaranteed to have no redeemable functionality whatsoever. There are plenty of helpful JavaScript libraries out there, and some that are of questionable utility, but none that are share our commitment to utter uselessness.\n\n---\n\nTitle: tomekw/whatever\nURL: N/A\nAuthor: N/A\nHighlights:\n- Stars: 281\n- Forks: 115\n- Watchers: 281\n- Open issues: 2\n- Default branch: master\n- Created: 2014-10-10T15:51:31Z\n\n---\n\nTitle:thecodersroom/the-button-that-does-nothing\nURL: 2025-10-06T18:39:22.000Z\nAuthor: N/A\nHighlights:\n# Repository: thecodersroom/the-button-that-does-nothing\n...\nthat looks important\n...\nliterally does nothing\n...\n- Stars: 21\n- Forks: 67\n- Watchers: 0\n- Open issues: 24\n- Primary language: JavaScript\n- Languages: JavaScript (45.4%), CSS (41.9%), HTML (12.7%)\n- License: MIT License (MIT)\n- Topics: css, hacktoberfest, hacktoberfest-accepted, html, javascript\n- Default branch: main\n- Homepage: Created: 2025-10-06T18:39:22Z\n- Last push: 2025-11-04T09:02:47Z\n- Contributors: 60 (top: AbdulKhadhar, AdZard69, Moksh-Mutreja, AshaSaini-033, akshith2855, kronpatel, ahmedrazabaloch, AhishRagav, Nl-T-lN, vanshikap21)\n...\nbuilt entirely with **\n\n---\n\nTitle: GitHub - niltok64/the-useless-collection: A collection of software that serve no purpose other than waste your time. Forking is encouraged!\nURL: 2021-11-30T09:35:25.000Z\nAuthor: niltok64\nHighlights:\n64/the\n...\nuseless-collection\n...\n[\nStar\n1\n]()\n*\nA collection of software that serve no purpose other than waste your time. Forking is encouraged!\n[niltok64.github.io/the-useless-collection/]()\n### License\n...\nstar\n]() [\n0\n...\n://github.com/niltok64/the-useless-collection/forks)\n...\n://github.com/niltok64/the-useless-collection/branches) [\n...\n://github.com/niltok64/the-useless-collection/tags) [\nActivity\n...\n://github.\n...\n/nilt\n...\nthe-useless-collection\n...\n[\n**1**\nstar\n]()\n...\n### Watchers\n\n---\n\nTitle: GitHub - needless-org/needless: The JavaScript library you never asked for, never needed, and probably shouldn't use. But here we are!\nURL: 2023-09-05T14:04:16.000Z\nAuthor: needless-org\nHighlights:\nGitHub - needless-org/needless: The JavaScript library you neverasked for, never needed, and probably shouldn't use. But here we are!\n...\n[Skip to\n...\nneedless-org/needless)\n...\nhttps://github\n...\ncom/login?return_to=/needless-org/needless)\n...\n[\nStar\n1\n]()\n*\nThe JavaScript library you neverasked for, never needed, and probably shouldn't use. But here we are!\n...\n[\n1\nstar\n]() [\n0\nforks\n]() [\nBranches\n]() [\nTags\n]() [\nActivity\n]()\n[\n...\nneedless-\n...\nneedless)\n...\nThe JavaScript library you never asked for, never needed,and probably shouldn't use. But here we are!\n...\n\"My project is running too\n...\nwish there was\n...\nadd some completely unnecessary\n...\nno further!\n...\nIntroducing `needless-js`, the world'sfirst and foremost pointless library. We bring the \"Why?\" into \"Why is this even a thing?\".\n## Features\n...\n1. **Absolutely Useless**: Doesn't fix any problems because it doesn't know what problems are.\n2. **Hilariously Redundant**: Why have one function when you can have ten doing the same thing?\n3. **Zero Dependencies**: Mainly because no other library wants to be associated with us.\n4. **Eco-friendly**: Uses 0% CPU because it does absolutely nothing.\n## Installation\n...\n[\n**1**\nstar\n...\n]()\n\n---\n\nTitle: GitHub - terremoth/awesome-hilarious-repos\nURL: N/A\nAuthor: N/A\nHighlights:\nAwesome Hilarious Github Repositories. Awesome license. GitHub contributors. List of all (?) available and funniest Github repos. Contribute if you know others!\n\n---\n\nTitle: SuavePirate/Xamarin.Yeet\nURL: 2020-02-22T21:16:23.000Z\nAuthor: N/A\nHighlights:\n# Repository: SuavePirate/Xamarin.Yeet\n...\nAn absolutelyuseless tool to yeet your components off the screen.\n...\n- Stars: 2\n- Forks: 1\n- Watchers: 1\n- Open issues: 0\n- Primary language: C#\n- Languages: C#\n- License: MIT License (MIT)\n- Default branch: master\n- Created: 2020-02-22T21:16:23Z\n- Last push: 2020-02-22T21:31:11Z\n- Contributors: 1 (top: SuavePirate)\n...\nAn absolutely useless tool to yeet your components off the screen.\n\n---\n\nTitle: nico-garnier/useless-repo\nURL: 2020-12-03T11:49:46.000Z\nAuthor: N/A\nHighlights:\n# Repository: nico-garnier/useless-repo\n...\n- Stars:1\n- Forks: 0\n- Watchers: 2\n- Open issues: 0\n- Default branch: main\n- Created: 2020-12-03T11:49:46Z\n- Last push: 2020-12-03T14:16:57Z\n- Contributors: 2 (top: nico-garnier, eilenilec)\n...\n# useless-repo\n...\nFor github stat on top collaborators only.\n\n---\n\nTitle: elierotenberg/useless | GitHub | Open Source Insights\nURL: N/A\nAuthor: N/A\nHighlights:\nelierotenberg/useless | GitHub | Open Source Insights\n...\n# elierotenberg/useless\n...\n33 stars", + "_meta": { + "searchTime": 1071.9 + } + } + ] + }, + "jsonrpc": "2.0", + "id": 3 +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316.md b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316.md new file mode 100644 index 0000000..629c4dd --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/01-exa-web-search-exa-165316.md @@ -0,0 +1,183 @@ +# 관찰 로그 01 - exa / `web_search_exa` + +- 출처: `MCP_툴_호출_통신_로그_(JSON-RPC) (1).pdf` +- 원본 생성 시각: 2026-07-13 17:10:33 UTC +- 성격: Agent Builder 솔루션에서 수행한 관찰용 실행 로그. 본 문서는 구현 계약이나 테스트 fixture가 아니다. + +## 헤더 및 실행 메타데이터 + +| 항목 | 원본 기록 값 | +|---|---| +| transport type | `mcp-http` | +| request_id | `045992d3-9683-4409-9613-e355a3eb05e1` | +| tool_uid | `f44207bd-9afb-4bbe-88d7-1cadf9e2779b` | +| Tool version | `2.0` | +| 실행 시각 | 2026-07-13 16:53:16 UTC | +| 결과 | ok · 1896ms · truncated=False | +| JSON-RPC 프레임 수 | 5 | +| lifecycle | `initialize → notifications/initialized → tools/call` | + +### HTTP 헤더 기록 범위 + +원본 PDF에는 개별 HTTP request/response header 값이 기록되어 있지 않다. 원본 표의 `request_id`, `tool_uid`, `version`은 실행 메타데이터이며 HTTP header라고 단정하지 않는다. + +원본 공통 안내에는 시크릿 및 인증 header가 로깅 전에 `[REDACTED]`로 마스킹되었다고 명시되어 있다. 따라서 Authorization, `Mcp-Session-Id` 등 실제 HTTP header 값은 이 문서에 포함하지 않는다. + +## JSON-RPC 통신 전문 + +아래는 원본 PDF의 JSON-RPC 프레임을 순서대로 옮긴 전사본이다. 긴 `result.content[].text` 문자열은 PDF의 시각적 줄바꿈을 보존했으므로, 이 블록 전체를 기계 실행용 단일 JSON payload로 사용하지 않는다. + +```text +16:53:16 요청 → initialize +{ +"jsonrpc": "2.0", +"id": 1, +"method": "initialize", +"params": { +"protocolVersion": "2025-06-18", +"capabilities": {}, +"clientInfo": { +"name": "toolbox-executor", +"version": "0.1.0" +} +} +} +16:53:16 ← 응답 initialize +{ +"result": { +"protocolVersion": "2025-06-18", +"capabilities": { +"tools": { +"listChanged": true +}, +"prompts": { +"listChanged": true +}, +"resources": { +"listChanged": true +} +}, +"serverInfo": { +"name": "exa-search-server", +"title": "Exa", +"version": "3.2.1", +"websiteUrl": "", +"icons": [ +{ +"src": "", +"mimeType": "image/png", +"sizes": [ +"32x32" +] +} +] +} +}, +"jsonrpc": "2.0", +"id": 1 +} +16:53:16 요청 → notifications/initialized +{ +"jsonrpc": "2.0", +"method": "notifications/initialized" +} +16:53:17 요청 → tools/call +{ +"jsonrpc": "2.0", +"id": 3, +"method": "tools/call", +"params": { +"name": "web_search_exa", +"arguments": { +"query": "most popular useless github repository stars useless repo", +"numResults": 10.0 +} +} +} +16:53:18 ← 응답 tools/call +{ +"result": { +"content": [ +{ +"type": "text", +"text": "Title: btahir/uselesshooks\nURL: N/A\nAuthor: N/A\nHighlights:\n# btahir/us\n...\n- Stars: 125\n- Forks: 9\n- Watchers: 125\n- Open +issues: 3\n- License: MIT License\n- Default branch: main\n- Created: 2022-12-18T03:03:19Z\n\n---\n\n +Title: Useless-Garbage-Institute/useless-garbage\nURL: 2015-08-27T22:14:06.000Z\nAuthor: N/A\nHighlights:\n# Repository: Useles +s-Garbage-Institute/useless-garbage\n...\nThe most profoundly useless javascript library ever invente +d.\n...\n- Stars: 6\n- Forks: 1\n- Watchers: 6\n- Open issues: 1\n- Primary language: JavaScript\n- L +anguages: JavaScript\n- Default branch: master\n- Created: 2015-08-27T22:14:06Z\n- Last push: 2015-09 +-01T01:14:37Z\n- Contributors: 2 (top: kriztynna, cgalbiati)\n...\nThe useless-garbage library is the +only npm module that is guaranteed to have no redeemable functionality whatsoever. There are plenty o +f helpful JavaScript libraries out there, and some that are of questionable utility, but none that ar +e share our commitment to utter uselessness.\n\n---\n\nTitle: tomekw/whatever\nURL: N/A\nAuthor: N/A\nHighlights:\n- Stars: 281\n- Forks: 115\n- Watcher +s: 281\n- Open issues: 2\n- Default branch: master\n- Created: 2014-10-10T15:51:31Z\n\n---\n\nTitle: +thecodersroom/the-button-that-does-nothing\nURL: 2025-10-06T18:39:22.000Z\nAuthor: N/A\nHighlights:\n# Repository: thecodersro +om/the-button-that-does-nothing\n...\nthat looks important\n...\nliterally does nothing\n...\n- Star +s: 21\n- Forks: 67\n- Watchers: 0\n- Open issues: 24\n- Primary language: JavaScript\n- Languages: Ja +vaScript (45.4%), CSS (41.9%), HTML (12.7%)\n- License: MIT License (MIT)\n- Topics: css, hacktoberfe +st, hacktoberfest-accepted, html, javascript\n- Default branch: main\n- Homepage: Created: 2025-10-06T18:39:22Z\n- Last push: 2025-11-0 +4T09:02:47Z\n- Contributors: 60 (top: AbdulKhadhar, AdZard69, Moksh-Mutreja, AshaSaini-033, akshith28 +55, kronpatel, ahmedrazabaloch, AhishRagav, Nl-T-lN, vanshikap21)\n...\nbuilt entirely with **\n\n--- +\n\nTitle: GitHub - niltok64/the-useless-collection: A collection of software that serve no purpose o +ther than waste your time. Forking is encouraged!\nURL: 2021-11-30T09:35:25.000Z\nAuthor: niltok64\nHighlights:\n64/the\n...\nuseless-col +lection\n...\n[\nStar\n1\n]()\n* +\nA collection of software that serve no purpose other than waste your time. Forking is encouraged!\n +[niltok64.github.io/the-useless-collection/]()\n# +## License\n...\nstar\n]() [\n0 +\n...\n://github.com/niltok64/the-useless-collection/forks)\n...\n://github.com/niltok64/the-useless- +collection/branches) [\n...\n://github.com/niltok64/the-useless-collection/tags) [\nActivity +\n...\n://github.\n...\n/nilt\n...\nthe-useless-collection\n...\n[\n**1**\nstar\n]()\n...\n### Watchers\n\n---\n\nTitle: GitHub - needless- +org/needless: The JavaScript library you never asked for, never needed, and probably shouldn't use. B +ut here we are!\nURL: 2023-09-05T14:04:16.000Z +\nAuthor: needless-org\nHighlights:\nGitHub - needless-org/needless: The JavaScript library you never +asked for, never needed, and probably shouldn't use. But here we are!\n...\n[Skip to\n...\nneedless-o +rg/needless)\n...\nhttps://github\n...\ncom/login?return_to=/needless-org/needless)\n...\n[\nStar\n1 +\n]()\n*\nThe JavaScript library you never +asked for, never needed, and probably shouldn't use. But here we are!\n...\n[\n1\nstar\n]() [\n0\nforks\n]() [\nBranches\n]() [\nTags\n]() [\nActivity\n]()\n[\n...\nneedless-\n...\nneedless)\n...\nThe JavaScript library you never asked for, never needed, +and probably shouldn't use. But here we are!\n...\n\"My project is running too\n...\nwish there was +\n...\nadd some completely unnecessary\n...\nno further!\n...\nIntroducing `needless-js`, the world's +first and foremost pointless library. We bring the \"Why?\" into \"Why is this even a thing?\".\n## F +eatures\n...\n1. **Absolutely Useless**: Doesn't fix any problems because it doesn't know what proble +ms are.\n2. **Hilariously Redundant**: Why have one function when you can have ten doing the same thi +ng?\n3. **Zero Dependencies**: Mainly because no other library wants to be associated with us.\n4. ** +Eco-friendly**: Uses 0% CPU because it does absolutely nothing.\n## Installation\n...\n[\n**1**\nstar +\n...\n]()\n\n---\n\nTitle: GitHub - terremoth/a +wesome-hilarious-repos\nURL: N/A\n +Author: N/A\nHighlights:\nAwesome Hilarious Github Repositories. Awesome license. GitHub contributor +s. List of all (?) available and funniest Github repos. Contribute if you know others!\n\n---\n\nTitl +e: SuavePirate/Xamarin.Yeet\nURL: 2020-02-2 +2T21:16:23.000Z\nAuthor: N/A\nHighlights:\n# Repository: SuavePirate/Xamarin.Yeet\n...\nAn absolutely +useless tool to yeet your components off the screen.\n...\n- Stars: 2\n- Forks: 1\n- Watchers: 1\n- O +pen issues: 0\n- Primary language: C#\n- Languages: C#\n- License: MIT License (MIT)\n- Default branc +h: master\n- Created: 2020-02-22T21:16:23Z\n- Last push: 2020-02-22T21:31:11Z\n- Contributors: 1 (to +p: SuavePirate)\n...\nAn absolutely useless tool to yeet your components off the screen.\n\n---\n\nTi +tle: nico-garnier/useless-repo\nURL: 2020- +12-03T11:49:46.000Z\nAuthor: N/A\nHighlights:\n# Repository: nico-garnier/useless-repo\n...\n- Stars: +1\n- Forks: 0\n- Watchers: 2\n- Open issues: 0\n- Default branch: main\n- Created: 2020-12-03T11:49:4 +6Z\n- Last push: 2020-12-03T14:16:57Z\n- Contributors: 2 (top: nico-garnier, eilenilec)\n...\n# usele +ss-repo\n...\nFor github stat on top collaborators only.\n\n---\n\nTitle: elierotenberg/useless | Git +Hub | Open Source Insights\nURL: N/A\nAuthor: N/A\nHighlights:\nelierotenberg/useless | GitHub | Open Source Insights\n...\n# eliero +tenberg/useless\n...\n33 stars", +"_meta": { +"searchTime": 1071.9 +} +} +] +}, +"jsonrpc": "2.0", +"id": 3 +} +``` + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-initialize-request.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-initialize-request.json new file mode 100644 index 0000000..15a192b --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-initialize-request.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { + "name": "toolbox-executor", + "version": "0.1.0" + } + } +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-initialize-response.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-initialize-response.json new file mode 100644 index 0000000..f15b53e --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-initialize-response.json @@ -0,0 +1,34 @@ +{ + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { + "tools": { + "listChanged": true + }, + "prompts": { + "listChanged": true + }, + "resources": { + "listChanged": true + } + }, + "serverInfo": { + "name": "exa-search-server", + "title": "Exa", + "version": "3.2.1", + "websiteUrl": "", + "icons": [ + { + "src": "", + "mimeType": "image/png", + "sizes": [ + "32x32" + ] + } + ] + } + }, + "jsonrpc": "2.0", + "id": 1 +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-initialized-notification.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-initialized-notification.json new file mode 100644 index 0000000..9ed0b56 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-initialized-notification.json @@ -0,0 +1,5 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/initialized" +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-tools-call-request.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-tools-call-request.json new file mode 100644 index 0000000..2ab6acc --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-tools-call-request.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "web_search_exa", + "arguments": { + "query": "site:github.com \"does nothing\" stars", + "numResults": 10 + } + } +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-tools-call-response.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-tools-call-response.json new file mode 100644 index 0000000..307a7db --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324-tools-call-response.json @@ -0,0 +1,16 @@ +{ + "result": { + "content": [ + { + "type": "text", + "text": "Title: michaelb/do-nothing.vim\nURL: N/A\nAuthor: N/A\nHighlights:\nA vim plugin that does nothing. Because why not\n...\n- Stars: 121\n- Forks: 4\n- Watchers: 121\n- Open issues: 1\n- License: MIT License\n- Default branch: main\n- Created: 2021-07-06T07:35:05Z\n...\nThis plugin doesn't do anything.\n\n---\n\nTitle: silicakes/nada-js\nURL: N/A\nAuthor: N/A\nHighlights:\nA Library that does nothing\n...\n- Stars: 32\n- Forks: 0\n- Watchers: 32\n- Open issues: 3\n- License: MITLicense\n- Default branch: main\n- Created: 2019-05-29T22:52:59Z\n...\n### A Library that does nothing in or for your project\n...\nNadaJS gives a single guarantee: Aside from taking space, it will do absolutely nothing.\nNo matter what kind of project, architecture or constraints you might have, nadaJS always gets nothing done.\n\n---\n\nTitle: GitHub - azr/donothing: does nothing !\nURL: 2012-10-25T16:10:49.000Z\nAuthor: azr\nHighlights:\nGitHub - az\n...\ndonothing: does nothing !\n...\n[\nStar\n1\n]()\n...\n*\ndoes nothing !\n### License\n...\n[\n1\n...\nstar\n]() [\n0\n...\n://github.com/azr/donothing/forks) [\n...\n://github.com/azr/donothing/branches\n...\nTags\n](\n...\n://github.com/azr/donothing/tags\n...\n://github.com/azr/donothing/activity)\n...\n://github.com/\n...\n=/azr/donothing)\n...\ndoes nothing !\n...\n[\n**1**\nstar\n]()\n\n---\n\nTitle: imjakechapman/TheNothingApp\nURL: 2014-06-19T22:35:01.000Z\nAuthor: N/A\nHighlights:\nThe app that does literally nothing.\n...\n- Stars: 19\n- Forks: 2\n- Watchers: 19\n- Open issues: 2\n-Primary language: Swift\n- Languages: Swift (71.3%), Java (28.7%)\n- Default branch: master\n- Homepage: thenothingapp.com\n- Created: 2014-06-19T22:35:01Z\n- Last push: 2015-06-22T22:07:41Z\n- Contributors: 2 (top: imjakechapman, thiagokimo)\n...\nThe app that does literally nothing, hackernews/designernews/twitter approved best application for doing jack-diddley-squat.\n\n---\n\nTitle: thecodersroom/the-button-that-does-nothing\nURL: 2025-10-06T18:39:22.000Z\nAuthor: N/A\nHighlights:\n- Stars: 21\n- Forks: 67\n- Watchers:0\n- Open issues: 24\n- Primary language: JavaScript\n- Languages: JavaScript (45.4%), CSS (41.9%), HTML (12.7%)\n- License: MIT License (MIT)\n- Topics: css, hacktoberfest, hacktoberfest-accepted, html, javascript\n- Default branch: main\n- Homepage: Created: 2025-10-06T18:39:22Z\n- Last push: 2025-11-04T09:02:47Z\n- Contributors: 60(top: AbdulKhadhar, AdZard69, Moksh-Mutreja, AshaSaini-033, akshith2855, kronpatel, ahmedrazabaloch,AhishRagav, Nl-T-lN, vanshikap21)\n\n---\n\nTitle: seeschloss/nothing-to-see-here\nURL: N/A\nAuthor: N/A\nHighlights:\nAn NPM package which does nothing\n...\n- Stars: 1\n- Forks: 0\n- Watchers: 1\n- Open issues: 0\n- License: MIT License\n- Default branch: master\n- Created: 2015-01-21T08:32:44Z\n...\n## Usage ##\n\n var nothing = require('nothing-to-see-here');\n // Do nothing with nothing, since it does nothing at all.\n\n---\n\nTitle: 10xly/do-nothing\nURL: N/A\nAuthor: N/A\nHighlights:\n- Stars: 1\n- Forks: 0\n- Watchers: 1\n- Open issues: 0\n- Default branch: main\n- Created: 2024-09-06T19:20:09Z\n- Fork: yes\n...\nDo nothing.\n...\nIt's just another noop\n\n---\n\nTitle: Searchcode, repositories, users, issues, pull requests...\nURL: 2025-07-22T16:46:01.000Z\nAuthor: techsiddhi\nHighlights:\n.com/\n...\n](https://\n...\n* [Notifications]()You must be signed in to change notification settings\n* [Fork0]()\n* [Star1]()\n...\n[1star]()[0forks]()[Branches]()[Tags]()[Activity]()\n...\n[Star]()\n...\n/activity)\n...\n[**1**star]()\n### Watchers\n[**0**watching]()\n\n---\n\nTitle: NicusorN5/DoNothing\nURL: 2023-08-19T15:20:52.000Z\nAuthor: N/A\nHighlights:\nThis does nothing. Literally nothing.\n...\n- Stars: 0\n- Forks: 0\n- Watchers: 0\n- Open issues: 0\n- Primary language: Assembly\n- Languages: Assembly\n- License: MIT License (MIT)\n- Topics: assembly, nothing\n- Default branch: main\n- Created: 2023-08-19T15:20:52Z\n- Last push: 2023-08-19T15:29:03Z\n- Contributors: 1 (top: NicusorN5)\n- Releases: 1\n- Latest release: release (2023-08-19T15:29:03Z)\n- Archived: true\n...\nThis does nothing. Literally nothing.\n\n---\n\nTitle: README.md\nURL: N/A\nAuthor: N/A\nHighlights:\n# does_nothing : A library that does nothing\n...\n- `function a()`\n - Description: Doessome meaningless caluculation.\n - Input: nothing.\n - Output: nothing.\n...\n- `function b()`\n - Description: Does some meaningless caluculation.\n - Input: nothing.\n - Output: nothing.\n...\n- `function c()`\n - Description: Does some meaningless caluculation.\n - Input: nothing.\n - Output: nothing.\n...\nThough functions in this library are completely harmless and consume little cpu resource, not telling users that the program sometimes does a meaningless calculation or computes inefficiently may result in a criminal prosecution as the program may be against users' intention. (in Japan)", + "_meta": { + "searchTime": 976.1 + } + } + ] + }, + "jsonrpc": "2.0", + "id": 3 +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324.md b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324.md new file mode 100644 index 0000000..b01528b --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/02-exa-web-search-exa-165324.md @@ -0,0 +1,176 @@ +# 관찰 로그 02 - exa / `web_search_exa` + +- 출처: `MCP_툴_호출_통신_로그_(JSON-RPC) (1).pdf` +- 원본 생성 시각: 2026-07-13 17:10:33 UTC +- 성격: Agent Builder 솔루션에서 수행한 관찰용 실행 로그. 본 문서는 구현 계약이나 테스트 fixture가 아니다. + +## 헤더 및 실행 메타데이터 + +| 항목 | 원본 기록 값 | +|---|---| +| transport type | `mcp-http` | +| request_id | `536d9a7e-be3e-4847-8878-f71ae1d690ba` | +| tool_uid | `f44207bd-9afb-4bbe-88d7-1cadf9e2779b` | +| Tool version | `2.0` | +| 실행 시각 | 2026-07-13 16:53:24 UTC | +| 결과 | ok · 1768ms · truncated=False | +| JSON-RPC 프레임 수 | 5 | +| lifecycle | `initialize → notifications/initialized → tools/call` | + +### HTTP 헤더 기록 범위 + +원본 PDF에는 개별 HTTP request/response header 값이 기록되어 있지 않다. 원본 표의 `request_id`, `tool_uid`, `version`은 실행 메타데이터이며 HTTP header라고 단정하지 않는다. + +원본 공통 안내에는 시크릿 및 인증 header가 로깅 전에 `[REDACTED]`로 마스킹되었다고 명시되어 있다. 따라서 Authorization, `Mcp-Session-Id` 등 실제 HTTP header 값은 이 문서에 포함하지 않는다. + +## JSON-RPC 통신 전문 + +아래는 원본 PDF의 JSON-RPC 프레임을 순서대로 옮긴 전사본이다. 긴 `result.content[].text` 문자열은 PDF의 시각적 줄바꿈을 보존했으므로, 이 블록 전체를 기계 실행용 단일 JSON payload로 사용하지 않는다. + +```text +16:53:24 요청 → initialize +{ +"jsonrpc": "2.0", +"id": 1, +"method": "initialize", +"params": { +"protocolVersion": "2025-06-18", +"capabilities": {}, +"clientInfo": { +"name": "toolbox-executor", +"version": "0.1.0" +} +} +} +16:53:24 ← 응답 initialize +{ +"result": { +"protocolVersion": "2025-06-18", +"capabilities": { +"tools": { +"listChanged": true +}, +"prompts": { +"listChanged": true +}, +"resources": { +"listChanged": true +} +}, +"serverInfo": { +"name": "exa-search-server", +"title": "Exa", +"version": "3.2.1", +"websiteUrl": "", +"icons": [ +{ +"src": "", +"mimeType": "image/png", +"sizes": [ +"32x32" +] +} +] +} +}, +"jsonrpc": "2.0", +"id": 1 +} +16:53:24 요청 → notifications/initialized +{ +"jsonrpc": "2.0", +"method": "notifications/initialized" +} +16:53:24 요청 → tools/call +{ +"jsonrpc": "2.0", +"id": 3, +"method": "tools/call", +"params": { +"name": "web_search_exa", +"arguments": { +"query": "site:github.com \"does nothing\" stars", +"numResults": 10.0 +} +} +} +16:53:25 ← 응답 tools/call +{ +"result": { +"content": [ +{ +"type": "text", +"text": "Title: michaelb/do-nothing.vim\nURL: N/A\nAuthor: N/A\nHighlights:\nA vim plugin that does nothing. Because why not\n...\n- Star +s: 121\n- Forks: 4\n- Watchers: 121\n- Open issues: 1\n- License: MIT License\n- Default branch: main +\n- Created: 2021-07-06T07:35:05Z\n...\nThis plugin doesn't do anything.\n\n---\n\nTitle: silicakes/n +ada-js\nURL: N/A\nAuthor: N/A\nHighlights:\nA Libr +ary that does nothing\n...\n- Stars: 32\n- Forks: 0\n- Watchers: 32\n- Open issues: 3\n- License: MIT +License\n- Default branch: main\n- Created: 2019-05-29T22:52:59Z\n...\n### A Library that does nothin +g in or for your project\n...\nNadaJS gives a single guarantee: Aside from taking space, it will do a +bsolutely nothing.\nNo matter what kind of project, architecture or constraints you might have, nadaJ +S always gets nothing done.\n\n---\n\nTitle: GitHub - azr/donothing: does nothing !\nURL: 2012-10-25T16:10:49.000Z\nAuthor: azr\nHighlights:\nGitHub - az +\n...\ndonothing: does nothing !\n...\n[\nStar\n1\n]()\n...\n*\ndoes nothing !\n### License\n...\n[\n1\n...\nstar\n]() [\n0\n...\n://github.com/azr/donothing/forks) [\n...\n://github.com/azr/donothing/branch +es\n...\nTags\n](\n...\n://github.com/azr/donothing/tags\n...\n://github.com/azr/donothing/activity) +\n...\n://github.com/\n...\n=/azr/donothing)\n...\ndoes nothing !\n...\n[\n**1**\nstar\n]()\n\n---\n\nTitle: imjakechapman/TheNothingApp\nURL: 2014-06-19T22:35:01.000Z\nAuthor: N/A\nHighlights:\nTh +e app that does literally nothing.\n...\n- Stars: 19\n- Forks: 2\n- Watchers: 19\n- Open issues: 2\n- +Primary language: Swift\n- Languages: Swift (71.3%), Java (28.7%)\n- Default branch: master\n- Homepa +ge: thenothingapp.com\n- Created: 2014-06-19T22:35:01Z\n- Last push: 2015-06-22T22:07:41Z\n- Contribu +tors: 2 (top: imjakechapman, thiagokimo)\n...\nThe app that does literally nothing, hackernews/design +ernews/twitter approved best application for doing jack-diddley-squat.\n\n---\n\nTitle: thecodersroo +m/the-button-that-does-nothing\nURL: 2025-10-06T18:39:22.000Z\nAuthor: N/A\nHighlights:\n- Stars: 21\n- Forks: 67\n- Watchers: +0\n- Open issues: 24\n- Primary language: JavaScript\n- Languages: JavaScript (45.4%), CSS (41.9%), H +TML (12.7%)\n- License: MIT License (MIT)\n- Topics: css, hacktoberfest, hacktoberfest-accepted, htm +l, javascript\n- Default branch: main\n- Homepage: Created: 2025-10-06T18:39:22Z\n- Last push: 2025-11-04T09:02:47Z\n- Contributors: 60 +(top: AbdulKhadhar, AdZard69, Moksh-Mutreja, AshaSaini-033, akshith2855, kronpatel, ahmedrazabaloch, +AhishRagav, Nl-T-lN, vanshikap21)\n\n---\n\nTitle: seeschloss/nothing-to-see-here\nURL: N/A\nAuthor: N/A\nHighlights:\nAn NPM package whic +h does nothing\n...\n- Stars: 1\n- Forks: 0\n- Watchers: 1\n- Open issues: 0\n- License: MIT License +\n- Default branch: master\n- Created: 2015-01-21T08:32:44Z\n...\n## Usage ##\n\n var nothing = requi +re('nothing-to-see-here');\n // Do nothing with nothing, since it does nothing at all.\n\n---\n\nTitl +e: 10xly/do-nothing\nURL: N/A\nAuthor: N/A\nHighlig +hts:\n- Stars: 1\n- Forks: 0\n- Watchers: 1\n- Open issues: 0\n- Default branch: main\n- Created: 202 +4-09-06T19:20:09Z\n- Fork: yes\n...\nDo nothing.\n...\nIt's just another noop\n\n---\n\nTitle: Search +code, repositories, users, issues, pull requests...\nURL: 2025-07-22T16:46:01.000Z\nAuthor: techsiddhi\nHighlights:\n.com/\n...\n](https:// +\n...\n* [Notifications]()You must be si +gned in to change notification settings\n* [Fork0]()\n* [Star1]()\n...\n[1star] +()[0forks]()[Branches]()[Tags]()[Activity]()\n...\n[Star]()\n...\n/activity) +\n...\n[**1**star]()\n### Watchers\n[**0**watc +hing]()\n\n---\n\nTitle: NicusorN5/DoNothing\nUR +L: 2023-08-19T15:20:52.000Z\nAuthor: N/A\nHighli +ghts:\nThis does nothing. Literally nothing.\n...\n- Stars: 0\n- Forks: 0\n- Watchers: 0\n- Open issu +es: 0\n- Primary language: Assembly\n- Languages: Assembly\n- License: MIT License (MIT)\n- Topics: a +ssembly, nothing\n- Default branch: main\n- Created: 2023-08-19T15:20:52Z\n- Last push: 2023-08-19T1 +5:29:03Z\n- Contributors: 1 (top: NicusorN5)\n- Releases: 1\n- Latest release: release (2023-08-19T1 +5:29:03Z)\n- Archived: true\n...\nThis does nothing. Literally nothing.\n\n---\n\nTitle: README.md\nU +RL: N/A\nAuthor: N/A\nH +ighlights:\n# does_nothing : A library that does nothing\n...\n- `function a()`\n - Description: Does +some meaningless caluculation.\n - Input: nothing.\n - Output: nothing.\n...\n- `function b()`\n - De +scription: Does some meaningless caluculation.\n - Input: nothing.\n - Output: nothing.\n...\n- `func +tion c()`\n - Description: Does some meaningless caluculation.\n - Input: nothing.\n - Output: nothin +g.\n...\nThough functions in this library are completely harmless and consume little cpu resource, no +t telling users that the program sometimes does a meaningless calculation or computes inefficiently m +ay result in a criminal prosecution as the program may be against users' intention. (in Japan)", +"_meta": { +"searchTime": 976.1 +} +} +] +}, +"jsonrpc": "2.0", +"id": 3 +} +``` + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-initialize-request.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-initialize-request.json new file mode 100644 index 0000000..15a192b --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-initialize-request.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { + "name": "toolbox-executor", + "version": "0.1.0" + } + } +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-initialize-response.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-initialize-response.json new file mode 100644 index 0000000..f15b53e --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-initialize-response.json @@ -0,0 +1,34 @@ +{ + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { + "tools": { + "listChanged": true + }, + "prompts": { + "listChanged": true + }, + "resources": { + "listChanged": true + } + }, + "serverInfo": { + "name": "exa-search-server", + "title": "Exa", + "version": "3.2.1", + "websiteUrl": "", + "icons": [ + { + "src": "", + "mimeType": "image/png", + "sizes": [ + "32x32" + ] + } + ] + } + }, + "jsonrpc": "2.0", + "id": 1 +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-initialized-notification.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-initialized-notification.json new file mode 100644 index 0000000..9ed0b56 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-initialized-notification.json @@ -0,0 +1,5 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/initialized" +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-tools-call-request.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-tools-call-request.json new file mode 100644 index 0000000..b303112 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-tools-call-request.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "web_search_exa", + "arguments": { + "query": "site:github.com \"useless\" \"stars\" \"forks\" repository useless", + "numResults": 10 + } + } +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-tools-call-response.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-tools-call-response.json new file mode 100644 index 0000000..e109a59 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331-tools-call-response.json @@ -0,0 +1,16 @@ +{ + "result": { + "content": [ + { + "type": "text", + "text": "Title: gianmarco-mameli/uselessrepo\nURL: 2023-04-17T12:26:41.000Z\nAuthor: N/A\nHighlights:\n# Repository: gianmarco-mameli/uselessrepo\n...\nThis useless repo is created for versioning a version\n...\n- Stars: 3\n- Forks:2\n- Watchers: 1\n- Open issues: 0\n- Default branch: main\n- Created: 2023-04-17T12:26:41Z\n- Last push: 2025-05-09T07:38:26Z\n- Contributors: 3 (top: gianmarco-mameli, s0ys4uc3, CrownKingClown)\n...\n# uselessrepo\n...\nThis useless repo is created for versioning a version\n\n---\n\nTitle: tinkerhub/useless_project_temp\nURL: N/A\nAuthor: N/A\nHighlights:\n# tinkerhub/useless_project_temp\n...\n- Stars: 15\n- Forks: 854\n- Watchers: 15\n- Open issues: 42\n- Default branch: main\n- Created: 2024-10-23T10:26:22Z\n\n---\n\nTitle: btahir/uselesshooks\nURL: N/A\nAuthor: N/A\nHighlights:\n# btahir/uselesshooks\n...\nA Collection of U\n...\ness React Hooks to impress your coworkers\n...\n- Stars: 125\n- Forks: 9\n- Watchers: 125\n- Open issues: 3\n- License: MIT License\n- Defaultbranch: main\n- Created: 2022-12-18T03:03:19Z\n\n---\n\nTitle: dorktoast/turd\nURL: N/A\nAuthor: N/A\nHighlights:\nThe Useless Repo (Duh)\n...\n- Stars: 2\n- Forks: 0\n- Watchers: 2\n- Open issues: 0\n- Default branch: master\n- Created: 2023-08-22T08:54:42Z\n\n##\n...\n# TURD: The Useless Repo (Duh)\n...\nThis is the most useless repo on github. Insidethis repo you will find scripts that make your soul hurt.\n\n---\n\nTitle: elierotenberg/useless\nURL: 2020-05-10T09:53:12.000Z\nAuthor: N/A\nHighlights:\n# Repository: elierotenberg/useless\n...\nUseless React hooks\n...\n- Stars: 33\n- Forks: 1\n- Watchers: 1\n- Open issues: 6\n- Primary language: TypeScript\n- Languages: TypeScript (68.7%), JavaScript (31.3%)\n- Default branch: master\n- Created: 2020-05-10T09:53:12Z\n- Last push: 2022-03-26T16:38:59Z\n- Contributors: 1 (top: elierotenberg)\n...\nThis is a library of useless hooks for common non-use-cases.\n\n---\n\nTitle: GitHub - niltok64/the-useless-collection: A collection of softwarethat serve no purpose other than waste your time. Forking is encouraged!\nURL: 2021-11-30T09:35:25.000Z\nAuthor: niltok64\nHighlights:\nGitHub - niltok64/the-useless-collection: A collection of software that serve no purpose other than waste your time. Forking is encouraged!\n[Skip to content](#start-of-content)\n## Navigation Menu\nToggle navigation\n...\n](.\n...\n/\n**\n[the-useless-collection]()\n**\nPublic\n* [Notifications\n]() You must be signed in to change notification settings\n* [Fork\n0\n...\n]()\n*\n...\n[\nStar\n1\n]()\n*\nA collection of software that serve no purpose other than waste your time. Forking is encouraged!\n[niltok64.github.io/the-useless-collection/]()\n### License\n...\nstar\n]() [\n0\n...\nforks\n]() [\nBranches\n]() [\nTags\n]() [\nActivity\n]()\n[\n...\nA collection of software that serve no purpose other than waste your time. Forking is encouraged!\n...\nRequires Python 3.9for building.\n## Usage\n...\nTo use on Unix-based systems run \"pyinstaller main.\n...\n--onefile\"\n...\nline in any of the folders. You can also use the executables in\n...\n`automated-build/` folder to build all the programs at once.\n## About\nA collection of software that serve no purpose other than waste your time. Forking is encouraged!\n[niltok64.github.io/the-useless-collection/]()\n...\nuseless-collection/activity)\n...\n[\n**1**\nstar\n]()\n### Watchers\n...\n[\n**1**\n...\ncom/niltok64/the-useless-collection/watchers)\n...\n[\n**0**\nforks\n]()\n[\n\n---\n\nTitle: erikvorhes/Useless-JS\nURL: N/A\nAuthor: N/A\nHighlights:\n# erikvorhes/Useless-JS\n...\nScripts that do nothing or overcomplicate things.\n...\n- Stars: 8\n- Forks: 3\n- Watchers: 8\n- Openissues: 1\n- License: Do What The F*ck You Want To Public License\n- Default branch: master\n- Created: 2011-08-19T14:09:26Z\n\n##\n...\n# Useless JS\n...\nThis is a collection of scripts that don't really do anything useful.\n...\nI've created a couple script files to get us started. Please add your own!\n\n---\n\nTitle: stac47/libuseless\nURL: N/A\nAuthor: stac47\nHighlights:\n# stac47/libuseless\n...\nThe most useless C++ library in the world\n...\n- Stars: 0\n- Forks: 0\n- Watchers: 0\n- Open issues: 0\n- License: GNU General Public Licensev3.0\n- Default branch: main\n- Created: 2021-01-29T13:34:01Z\n...\nThe most useless C++ library in the world\n\n---\n\nTitle: jeku/useless\nURL: 2012-08-02T14:05:01.000Z\nAuthor: jeku\nHighlights:\n# Repository: jeku/useless\n\nquite useless\n...\n- Stars:1\n- Forks: 0\n- Watchers: 1\n- Open issues: 0\n- Default branch: master\n- Created: 2012-08-02T14:05:01Z\n- Last push: 2012-08-02T14:05:01Z\n- Contributors: 1 (top: jeku)\n\n---\n\nTitle: barelyhuman/useless\nURL: N/A\nAuthor: N/A\nHighlights:\n# barelyhuman/useless\n...\nA set of useless utilities for javascript\n...\n- Stars: 6\n- Forks: 0\n- Watchers: 6\n- Open issues: 0\n- License: MIT License\n- Default branch: dev\n- Created: 2022-04-14T08:52:31Z\n\n## Languages\n...\n## README\n\n \n \n A set of useless utilities for javascript \n\n## Documentation\n...\nalready exist somewhere\n...\nand I'm just writing\n...\nto feel good about having my own set of utilties.", + "_meta": { + "searchTime": 1216.7 + } + } + ] + }, + "jsonrpc": "2.0", + "id": 3 +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331.md b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331.md new file mode 100644 index 0000000..25bcdaf --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/03-exa-web-search-exa-165331.md @@ -0,0 +1,178 @@ +# 관찰 로그 03 - exa / `web_search_exa` + +- 출처: `MCP_툴_호출_통신_로그_(JSON-RPC) (1).pdf` +- 원본 생성 시각: 2026-07-13 17:10:33 UTC +- 성격: Agent Builder 솔루션에서 수행한 관찰용 실행 로그. 본 문서는 구현 계약이나 테스트 fixture가 아니다. + +## 헤더 및 실행 메타데이터 + +| 항목 | 원본 기록 값 | +|---|---| +| transport type | `mcp-http` | +| request_id | `5720da6c-68cc-44f3-8a1f-73088bddc23d` | +| tool_uid | `f44207bd-9afb-4bbe-88d7-1cadf9e2779b` | +| Tool version | `2.0` | +| 실행 시각 | 2026-07-13 16:53:31 UTC | +| 결과 | ok · 2070ms · truncated=False | +| JSON-RPC 프레임 수 | 5 | +| lifecycle | `initialize → notifications/initialized → tools/call` | + +### HTTP 헤더 기록 범위 + +원본 PDF에는 개별 HTTP request/response header 값이 기록되어 있지 않다. 원본 표의 `request_id`, `tool_uid`, `version`은 실행 메타데이터이며 HTTP header라고 단정하지 않는다. + +원본 공통 안내에는 시크릿 및 인증 header가 로깅 전에 `[REDACTED]`로 마스킹되었다고 명시되어 있다. 따라서 Authorization, `Mcp-Session-Id` 등 실제 HTTP header 값은 이 문서에 포함하지 않는다. + +## JSON-RPC 통신 전문 + +아래는 원본 PDF의 JSON-RPC 프레임을 순서대로 옮긴 전사본이다. 긴 `result.content[].text` 문자열은 PDF의 시각적 줄바꿈을 보존했으므로, 이 블록 전체를 기계 실행용 단일 JSON payload로 사용하지 않는다. + +```text +16:53:31 요청 → initialize +{ +"jsonrpc": "2.0", +"id": 1, +"method": "initialize", +"params": { +"protocolVersion": "2025-06-18", +"capabilities": {}, +"clientInfo": { +"name": "toolbox-executor", +"version": "0.1.0" +} +} +} +16:53:31 ← 응답 initialize +{ +"result": { +"protocolVersion": "2025-06-18", +"capabilities": { +"tools": { +"listChanged": true +}, +"prompts": { +"listChanged": true +}, +"resources": { +"listChanged": true +} +}, +"serverInfo": { +"name": "exa-search-server", +"title": "Exa", +"version": "3.2.1", +"websiteUrl": "", +"icons": [ +{ +"src": "", +"mimeType": "image/png", +"sizes": [ +"32x32" +] +} +] +} +}, +"jsonrpc": "2.0", +"id": 1 +} +16:53:31 요청 → notifications/initialized +{ +"jsonrpc": "2.0", +"method": "notifications/initialized" +} +16:53:31 요청 → tools/call +{ +"jsonrpc": "2.0", +"id": 3, +"method": "tools/call", +"params": { +"name": "web_search_exa", +"arguments": { +"query": "site:github.com \"useless\" \"stars\" \"forks\" repository useless", +"numResults": 10.0 +} +} +} +16:53:33 ← 응답 tools/call +{ +"result": { +"content": [ +{ +"type": "text", +"text": "Title: gianmarco-mameli/uselessrepo\nURL: 2023-04-17T12:26:41.000Z\nAuthor: N/A\nHighlights:\n# Repository: gianmarco-mamel +i/uselessrepo\n...\nThis useless repo is created for versioning a version\n...\n- Stars: 3\n- Forks: +2\n- Watchers: 1\n- Open issues: 0\n- Default branch: main\n- Created: 2023-04-17T12:26:41Z\n- Last p +ush: 2025-05-09T07:38:26Z\n- Contributors: 3 (top: gianmarco-mameli, s0ys4uc3, CrownKingClown)\n...\n +# uselessrepo\n...\nThis useless repo is created for versioning a version\n\n---\n\nTitle: tinkerhub/ +useless_project_temp\nURL: N/A\nAutho +r: N/A\nHighlights:\n# tinkerhub/useless_project_temp\n...\n- Stars: 15\n- Forks: 854\n- Watchers: 15 +\n- Open issues: 42\n- Default branch: main\n- Created: 2024-10-23T10:26:22Z\n\n---\n\nTitle: btahir/ +uselesshooks\nURL: N/A\nAuthor: N/A\nHighlight +s:\n# btahir/uselesshooks\n...\nA Collection of U\n...\ness React Hooks to impress your coworkers +\n...\n- Stars: 125\n- Forks: 9\n- Watchers: 125\n- Open issues: 3\n- License: MIT License\n- Default +branch: main\n- Created: 2022-12-18T03:03:19Z\n\n---\n\nTitle: dorktoast/turd\nURL: N/A\nAuthor: N/A\nHighlights:\nThe Useless Repo (Duh)\n...\n- Stars: 2 +\n- Forks: 0\n- Watchers: 2\n- Open issues: 0\n- Default branch: master\n- Created: 2023-08-22T08:54: +42Z\n\n##\n...\n# TURD: The Useless Repo (Duh)\n...\nThis is the most useless repo on github. Inside +this repo you will find scripts that make your soul hurt.\n\n---\n\nTitle: elierotenberg/useless\nUR +L: 2020-05-10T09:53:12.000Z\nAuthor: N/A\nHigh +lights:\n# Repository: elierotenberg/useless\n...\nUseless React hooks\n...\n- Stars: 33\n- Forks: 1 +\n- Watchers: 1\n- Open issues: 6\n- Primary language: TypeScript\n- Languages: TypeScript (68.7%), J +avaScript (31.3%)\n- Default branch: master\n- Created: 2020-05-10T09:53:12Z\n- Last push: 2022-03-26 +T16:38:59Z\n- Contributors: 1 (top: elierotenberg)\n...\nThis is a library of useless hooks for commo +n non-use-cases.\n\n---\n\nTitle: GitHub - niltok64/the-useless-collection: A collection of software +that serve no purpose other than waste your time. Forking is encouraged!\nURL: 2021-11-30T09:35:25.000Z\nAuthor: niltok64\nHighlights:\nG +itHub - niltok64/the-useless-collection: A collection of software that serve no purpose other than wa +ste your time. Forking is encouraged!\n[Skip to content](#start-of-content)\n## Navigation Menu\nTogg +le navigation\n...\n](.\n...\n/\n**\n[the-useless-collection]()\n**\nPublic\n* [Notifications\n]() You must be signed in to change notification settings\n* [Fork\n0 +\n...\n]()\n*\n...\n[\nStar\n1 +\n]()\n*\nA collection of softwa +re that serve no purpose other than waste your time. Forking is encouraged!\n[niltok64.github.io/the- +useless-collection/]()\n### License\n...\nstar\n] +() [\n0\n...\nforks\n]() [\nBranches\n]() [\nTags\n]() [\nActiv +ity\n]()\n[\n...\nA collection of softwa +re that serve no purpose other than waste your time. Forking is encouraged!\n...\nRequires Python 3.9 +for building.\n## Usage\n...\nTo use on Unix-based systems run \"pyinstaller main.\n...\n--onefile +\"\n...\nline in any of the folders. You can also use the executables in\n...\n`automated-build/` fol +der to build all the programs at once.\n## About\nA collection of software that serve no purpose othe +r than waste your time. Forking is encouraged!\n[niltok64.github.io/the-useless-collection/]()\n...\nuseless-collection/activity)\n...\n[\n**1**\ns +tar\n]()\n### Watchers\n...\n[\n**1** +\n...\ncom/niltok64/the-useless-collection/watchers)\n...\n[\n**0**\nforks\n]()\n[\n\n---\n\nTitle: erikvorhes/Useless-JS\nURL: N/A\nAuthor: N/A\nHighlights:\n# erikvorhes/Useless-JS\n...\nS +cripts that do nothing or overcomplicate things.\n...\n- Stars: 8\n- Forks: 3\n- Watchers: 8\n- Open +issues: 1\n- License: Do What The F*ck You Want To Public License\n- Default branch: master\n- Create +d: 2011-08-19T14:09:26Z\n\n##\n...\n# Useless JS\n...\nThis is a collection of scripts that don't rea +lly do anything useful.\n...\nI've created a couple script files to get us started. Please add your o +wn!\n\n---\n\nTitle: stac47/libuseless\nURL: N/A\n +Author: stac47\nHighlights:\n# stac47/libuseless\n...\nThe most useless C++ library in the world +\n...\n- Stars: 0\n- Forks: 0\n- Watchers: 0\n- Open issues: 0\n- License: GNU General Public License +v3.0\n- Default branch: main\n- Created: 2021-01-29T13:34:01Z\n...\nThe most useless C++ library in t +he world\n\n---\n\nTitle: jeku/useless\nURL: 2012-08-02 +T14:05:01.000Z\nAuthor: jeku\nHighlights:\n# Repository: jeku/useless\n\nquite useless\n...\n- Stars: +1\n- Forks: 0\n- Watchers: 1\n- Open issues: 0\n- Default branch: master\n- Created: 2012-08-02T14:0 +5:01Z\n- Last push: 2012-08-02T14:05:01Z\n- Contributors: 1 (top: jeku)\n\n---\n\nTitle: barelyhuman/ +useless\nURL: N/A\nAuthor: N/A\nHighlights:\n# b +arelyhuman/useless\n...\nA set of useless utilities for javascript\n...\n- Stars: 6\n- Forks: 0\n- Wa +tchers: 6\n- Open issues: 0\n- License: MIT License\n- Default branch: dev\n- Created: 2022-04-14T08: +52:31Z\n\n## Languages\n...\n## README\n\n \n \n A set of useless utilities for javascript \n\n## Doc +umentation\n...\nalready exist somewhere\n...\nand I'm just writing\n...\nto feel good about having m +y own set of utilties.", +"_meta": { +"searchTime": 1216.7 +} +} +] +}, +"jsonrpc": "2.0", +"id": 3 +} +``` diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-initialize-request.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-initialize-request.json new file mode 100644 index 0000000..15a192b --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-initialize-request.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { + "name": "toolbox-executor", + "version": "0.1.0" + } + } +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-initialize-response.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-initialize-response.json new file mode 100644 index 0000000..f03e5e7 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-initialize-response.json @@ -0,0 +1,27 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { + "tools": { + "listChanged": true + }, + "prompts": { + "listChanged": true + }, + "resources": { + "listChanged": true + }, + "logging": {} + }, + "serverInfo": { + "name": "searchapi", + "title": "SearchAPI MCP Server", + "version": "1.0.0", + "websiteUrl": "" + }, + "instructions": "Use the available tools to get real-time search results. Each tool corresponds to a specific use case, which may be a full search engine or a specialized feature of an engine." + } +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-tools-call-request.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-tools-call-request.json new file mode 100644 index 0000000..2b7ca36 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-tools-call-request.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "youtube_search", + "arguments": { + "q": "EBS 공식 유튜브 채널", + "gl": "KR" + } + } +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-tools-call-response-visible-transcript.txt b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-tools-call-response-visible-transcript.txt new file mode 100644 index 0000000..d4790ee --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search-tools-call-response-visible-transcript.txt @@ -0,0 +1,12 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "content": [ + { + "type": "text", + "text": "{\n \"search_metadata\": {\n \"id\": \"search_Z18NJlvnOYI636jLXk3BOm7g\",\n \"status\": \"Success\",\n \"created_at\": \"2026-07-13T17:02:43Z\",\n \"request_time_taken\": 1.12,\n \"parsing_time_taken\": 0.04,\n \"total_time_taken\": 1.15,\n \"request_url\": \"" + } + ] + } +} diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search.md b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search.md new file mode 100644 index 0000000..bc1202a --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/04-searchapi-youtube-search.md @@ -0,0 +1,174 @@ +# 관찰 로그 04 - searchapi / `youtube_search` + +- 출처: `MCP_툴_호출_통신_로그_(JSON-RPC) (1).pdf` +- 원본 생성 시각: 2026-07-13 17:10:33 UTC +- 성격: Agent Builder 솔루션에서 수행한 관찰용 실행 로그. 본 문서는 구현 계약이나 테스트 fixture가 아니다. + +## 헤더 및 실행 메타데이터 + +| 항목 | 원본 기록 값 | +|---|---| +| transport type | `mcp-http` | +| request_id | `2ab618c9-8f2f-4163-ad11-d7118bc4d3ae` | +| tool_uid | `28553975-d471-4b67-a210-61cc636d537d` | +| Tool version | `2.0` | +| 실행 시각 | 2026-07-13 17:02:42 UTC | +| 결과 | ok · 2200ms · truncated=False | +| JSON-RPC 프레임 수 | 4 | +| lifecycle | `initialize → tools/call` | + +### HTTP 헤더 기록 범위 + +원본 PDF에는 개별 HTTP request/response header 값이 기록되어 있지 않다. 원본 표의 `request_id`, `tool_uid`, `version`은 실행 메타데이터이며 HTTP header라고 단정하지 않는다. + +원본 공통 안내에는 시크릿 및 인증 header가 로깅 전에 `[REDACTED]`로 마스킹되었다고 명시되어 있다. 따라서 Authorization, `Mcp-Session-Id` 등 실제 HTTP header 값은 이 문서에 포함하지 않는다. + +## JSON-RPC 통신 전문 + +아래는 원본 PDF의 JSON-RPC 프레임을 순서대로 옮긴 전사본이다. 긴 `result.content[].text` 문자열은 PDF의 시각적 줄바꿈을 보존했으므로, 이 블록 전체를 기계 실행용 단일 JSON payload로 사용하지 않는다. + +> 원본 PDF 자체가 `tools/call` 성공 응답의 끝부분을 `[+22537B]>`로 접어 표시한다. 따라서 이 문서는 PDF에 표시된 전문을 보존한 것이며, 접힌 22,537바이트의 원본 응답 데이터를 복원한 것은 아니다. + +```text +17:02:42 요청 → initialize +{ +"jsonrpc": "2.0", +"id": 1, +"method": "initialize", +"params": { +"protocolVersion": "2025-06-18", +"capabilities": {}, +"clientInfo": { +"name": "toolbox-executor", +"version": "0.1.0" +} +} +} +17:02:42 ← 응답 initialize +{ +"jsonrpc": "2.0", +"id": 1, +"result": { +"protocolVersion": "2025-06-18", +"capabilities": { +"tools": { +"listChanged": true +}, +"prompts": { +"listChanged": true +}, +"resources": { +"listChanged": true +}, +"logging": {} +}, +"serverInfo": { +"name": "searchapi", +"title": "SearchAPI MCP Server", +"version": "1.0.0", +"websiteUrl": "" +}, +"instructions": "Use the available tools to get real-time search results. Each tool corresponds t +o a specific use case, which may be a full search engine or a specialized feature of an engine." +} +} +17:02:42 요청 → tools/call +{ +"jsonrpc": "2.0", +"id": 3, +"method": "tools/call", +"params": { +"name": "youtube_search", +"arguments": { +"q": "EBS 공식 유튜브 채널", +"gl": "KR" +} +} +} +17:02:44 ← 응답 tools/call +{"jsonrpc": "2.0", "id": 3, "result": {"content": [{"type": "text", "text": "{\n \"search_metadata +\": {\n \"id\": \"search_Z18NJlvnOYI636jLXk3BOm7g\",\n \"status\": \"Success\",\n \"created_ +at\": \"2026-07-13T17:02:43Z\",\n \"request_time_taken\": 1.12,\n \"parsing_time_taken\": 0.0 +4,\n \"total_time_taken\": 1.15,\n \"request_url\": \"",\n \"htm +l_url\": \"",\n +\"json_url\": \""\n },\n +\"search_parameters\": {\n \"engine\": \"youtube\",\n \"q\": \"EBS 공식 유튜브 채널\",\n \"hl +\": \"en\",\n \"gl\": \"KR\"\n },\n \"search_information\": {\n \"total_results\": 6938629\n +},\n \"channels\": [\n {\n \"position\": 1,\n \"id\": \"UCFCtZJTuJhE18k8IXwmXTYQ\",\n +\"title\": \"EBS Documentary\",\n \"link\": \"",\n +\"description\": \"EBS offers a wide range of high-quality documentaries\\ndealing with subjects, suc +h as knowledge, science, culture and others.\",\n \"is_verified\": true,\n \"subscribers\": +5410000.0,\n \"thumbnail\": {\n \"static\": \"",\n \"r +ich\": \""\n }\n },\n {\n \"position\": 2,\n \"id +\": \"UC-swf20n5xdKW0waeLpXPFQ\",\n \"title\": \"EBS\",\n \"link\": \"",\n \"description\": \"EBS 공식 유튜브 채널 EBS 입니다. 언제나 여러분 곁엔 EBS가 그리 +고 언제나 EBS 곁엔 여러분이!\",\n \"subscribers\": 698000,\n \"thumbnail\": {\n \"stati +c\": \"",\n \"rich\": \""\n }\n +},\n {\n \"position\": 4,\n \"id\": \"UCNjQBiTSdoj2tCQLBGXFksw\",\n \"title\": \"EB +S 라디오 공식 채널\",\n \"link\": \"",\n \"description\": +\"책 읽어주는 라디오 + 외국어 라디오 (서울 수도권 104.5MHz) 한국교육방송 EBS 라디오 공식 운영 채널 입니다.\",\n +\"subscribers\": 208000,\n \"thumbnail\": {\n \"static\": \"",\n +\"rich\": \""\n }\n },\n {\n \"position\": 6,\n \"id\": \"U +CL44YGs2BXxtM4GZRH-wnrQ\",\n \"title\": \"EBS 지식채널e\",\n \"link\": \"",\n \"description\": \"우리 삶 속에서 잠깐, 5분 의미있는 순간으로 만드는 채널 공유하 +고 쌓여가는 지식 지식채널e 공식홈페이지 ...\",\n \"subscribers\": 147000,\n \"thumbnail\": {\n +\"static\": \"",\n \"rich\": \""\n +}\n },\n {\n \"position\": 8,\n \"id\": \"UCiFYUP4_TI70yCkkVJAlxoA\",\n \"title +\": \"EBS Collection - Nature\",\n \"link\": \"",\n +\"description\": \"Chosen among more than 310,000 digitalized sources from EBS archive, EBS Collectio +n gives you exclusive access to intriguing ...\",\n \"subscribers\": 1340000.0,\n \"thumbna +il\": {\n \"static\": \"",\n \"rich\": \""\n +}\n },\n {\n \"position\": 9,\n \"id\": \"UCl_tB4AqPkkxuYcJQHz6dMw\",\n \"title +\": \"EBSCulture (EBS 교양)\",\n \"link\": \"",\n \"d +escription\": \"Knowledge and information are becoming more important in all areas of today's societ +y. EBS provides the information to make ...\",\n \"is_verified\": true,\n \"subscribers\": +3020000.0,\n \"thumbnail\": {\n \"static\": \"",\n \"rich\": +\""\n }\n },\n {\n \"position\": 10,\n \"id\": \"UCbeZPOz8u +aHstEIbkqBOnGg\",\n \"title\": \"EBS 세계테마기행\",\n \"link\": \"",\n \"description\": \"EBS 세계테마기행 공식 유튜브 채널 : (구독하기) ⛵세계를 여행하며 만나는 다양한 문화와 풍경!\",\n \"subscribers\": 162000,\n \"thumb +nail\": {\n \"static\": \"",\n \"rich\": \""\n }\n },\n {\n \"position\": 11,\n \"id\": \"UCuuA38hvvuipqXFIP3BfL +EA\",\n \"title\": \"EBS 지식\",\n \"link\": \"",\n +\"description\": \"일상 속 지식 한 스푼! 각 분야의 전문가로 구성된 연사들의 강연까지! EBS의 고품격 지식·강연 프로 +그램 속 다양한 지식들을 큐레이팅 ...\",\n \"subscribers\": 298000,\n \"thumbnail\": {\n +\"static\": \"",\n \"rich\": \""\n +}\n },\n {\n \"position\": 13,\n \"id\": \"UC2dDb6up1sIc-5geTvNtgHA\",\n \"title +\": \"EBS 최고의 요리비결\",\n \"link\": \"",\n \"description\": \"이 채널은 \\\"EBS 최고의 요리비결\\\" 공식 유튜브 채널입니다. EBS 최고의 요리비 +결은 지난 20년 동안 요리의 대가들이 출연하여 누구나 ...\",\n \"subscribers\": 104000,\n \"thumbnai +l\": {\n \"static\": \"",\n \"rich\": \""\n +}\n },\n {\n \"position\": 14,\n \"id\": \"UC2DBKKki_gtyrrgT7HYU1CA\",\n \"title +\": \"EBS 국제다큐영화제 공식 채널\",\n \"link\": \"",\n +\"description\": \"EBS국제다큐영화제 EIDF 공식 Youtube 채널입니다. This is the official YouTube EIDF Chan +nel.\",\n \"subscribers\": 10100.0,\n \"thumbnail\": {\n \"static\": \"",\n \"rich\": \""\n }\n },\n {\n \"position\": 1 +5,\n \"id\": \"UCN3RfsR18gsH8PLI6R6PYSQ\",\n \"title\": \"EBS뉴스\",\n \"link\": \"",\n \"description\": \"한국교육방송공사 EBS 뉴스 채널입니다. EBS 뉴 +스의 TV 방송시간은 월~금 낮 12시, 저녁 6시 10분입니다. Republic of Korea's ...\",\n \"subscribers\": 5 +9900.0,\n \"thumbnail\": {\n \"static\": \"",\n \"rich +\": \" +``` diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-initialize-request.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-initialize-request.json new file mode 100644 index 0000000..15a192b --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-initialize-request.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { + "name": "toolbox-executor", + "version": "0.1.0" + } + } +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-initialize-response.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-initialize-response.json new file mode 100644 index 0000000..2dbaa1e --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-initialize-response.json @@ -0,0 +1,30 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { + "experimental": {}, + "logging": {}, + "prompts": { + "listChanged": true + }, + "resources": { + "subscribe": false, + "listChanged": true + }, + "tools": { + "listChanged": true + }, + "extensions": { + "io.modelcontextprotocol/ui": {} + } + }, + "serverInfo": { + "name": "langconnect-rag-mcp", + "version": "3.3.1" + }, + "instructions": "This server provides RAG tools over document collections. Call list_collections() first: it returns collections you can access plus per-collection role and allowed_tools (derived from RBAC scopes). Use search_documents/search_documents_batch/search_documents_with_context for retrieval. Use list_documents to inspect documents. Use add_document_text to add text. Use delete_document/delete_collection only when allowed_tools includes them. Call get_user_info() to get the current user's email and display name." + } +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-initialized-notification.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-initialized-notification.json new file mode 100644 index 0000000..9ed0b56 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-initialized-notification.json @@ -0,0 +1,5 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/initialized" +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-tools-call-request.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-tools-call-request.json new file mode 100644 index 0000000..58ad28f --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-tools-call-request.json @@ -0,0 +1,10 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "list_collections", + "arguments": {} + } +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-tools-call-response.json b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-tools-call-response.json new file mode 100644 index 0000000..f6ed813 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections-tools-call-response.json @@ -0,0 +1,40 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "content": [ + { + "type": "text", + "text": "{\"data\":[{\"name\":\"test\",\"id\":\"1caf198a-444b-4a89-a235-23e82bc3d68a\",\"metadata\":{\"description\":\"col\"},\"role\":0,\"allowed_tools\":[\"add_document_text\",\"delete_collection\",\"delete_document\",\"list_documents\",\"search_documents\",\"search_documents_batch\",\"search_documents_with_context\"],\"document_count\":2,\"chunk_count\":119}],\"success\":true,\"error\":null,\"request_id\":\"b3fa0787-dce3-449b-aff0-51a8d882e81d\"}" + } + ], + "structuredContent": { + "data": [ + { + "name": "test", + "id": "1caf198a-444b-4a89-a235-23e82bc3d68a", + "metadata": { + "description": "col" + }, + "role": 0, + "allowed_tools": [ + "add_document_text", + "delete_collection", + "delete_document", + "list_documents", + "search_documents", + "search_documents_batch", + "search_documents_with_context" + ], + "document_count": 2, + "chunk_count": 119 + } + ], + "success": true, + "error": null, + "request_id": "b3fa0787-dce3-449b-aff0-51a8d882e81d" + }, + "isError": false + } +} + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections.md b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections.md new file mode 100644 index 0000000..44ce6ec --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/05-langconnect-list-collections.md @@ -0,0 +1,140 @@ +# 관찰 로그 05 - langconnect / `list_collections` + +- 출처: `MCP_툴_호출_통신_로그_(JSON-RPC) (1).pdf` +- 원본 생성 시각: 2026-07-13 17:10:33 UTC +- 성격: Agent Builder 솔루션에서 수행한 관찰용 실행 로그. 본 문서는 구현 계약이나 테스트 fixture가 아니다. + +## 헤더 및 실행 메타데이터 + +| 항목 | 원본 기록 값 | +|---|---| +| transport type | `mcp-http` | +| request_id | `ac3d3c06-882e-4416-be34-a20c5f91662f` | +| tool_uid | `636e8cd0-3114-467f-b305-14e2c9cda744` | +| Tool version | `1.0` | +| 실행 시각 | 2026-07-13 17:08:46 UTC | +| 결과 | ok · 346ms · truncated=False | +| JSON-RPC 프레임 수 | 5 | +| lifecycle | `initialize → notifications/initialized → tools/call` | + +### HTTP 헤더 기록 범위 + +원본 PDF에는 개별 HTTP request/response header 값이 기록되어 있지 않다. 원본 표의 `request_id`, `tool_uid`, `version`은 실행 메타데이터이며 HTTP header라고 단정하지 않는다. + +원본 공통 안내에는 시크릿 및 인증 header가 로깅 전에 `[REDACTED]`로 마스킹되었다고 명시되어 있다. 따라서 Authorization, `Mcp-Session-Id` 등 실제 HTTP header 값은 이 문서에 포함하지 않는다. + +## JSON-RPC 통신 전문 + +아래는 원본 PDF의 JSON-RPC 프레임을 순서대로 옮긴 전사본이다. 긴 `result.content[].text` 문자열은 PDF의 시각적 줄바꿈을 보존했으므로, 이 블록 전체를 기계 실행용 단일 JSON payload로 사용하지 않는다. + +```text +17:08:46 요청 → initialize +{ +"jsonrpc": "2.0", +"id": 1, +"method": "initialize", +"params": { +"protocolVersion": "2025-06-18", +"capabilities": {}, +"clientInfo": { +"name": "toolbox-executor", +"version": "0.1.0" +} +} +} +17:08:46 ← 응답 initialize +{ +"jsonrpc": "2.0", +"id": 1, +"result": { +"protocolVersion": "2025-06-18", +"capabilities": { +"experimental": {}, +"logging": {}, +"prompts": { +"listChanged": true +}, +"resources": { +"subscribe": false, +"listChanged": true +}, +"tools": { +"listChanged": true +}, +"extensions": { +"io.modelcontextprotocol/ui": {} +} +}, +"serverInfo": { +"name": "langconnect-rag-mcp", +"version": "3.3.1" +}, +"instructions": "This server provides RAG tools over document collections. Call list_collections +() first: it returns collections you can access plus per-collection role and allowed_tools (derived f +rom RBAC scopes). Use search_documents/search_documents_batch/search_documents_with_context for retri +eval. Use list_documents to inspect documents. Use add_document_text to add text. Use delete_documen +t/delete_collection only when allowed_tools includes them. Call get_user_info() to get the current us +er's email and display name." +} +} +17:08:46 요청 → notifications/initialized +{ +"jsonrpc": "2.0", +"method": "notifications/initialized" +} +17:08:46 요청 → tools/call +{ +"jsonrpc": "2.0", +"id": 3, +"method": "tools/call", +"params": { +"name": "list_collections", +"arguments": {} +} +} +17:08:47 ← 응답 tools/call +{ +"jsonrpc": "2.0", +"id": 3, +"result": { +"content": [ +{ +"type": "text", +"text": "{\"data\":[{\"name\":\"test\",\"id\":\"1caf198a-444b-4a89-a235-23e82bc3d68a\",\"meta +data\":{\"description\":\"col\"},\"role\":0,\"allowed_tools\":[\"add_document_text\",\"delete_collect +ion\",\"delete_document\",\"list_documents\",\"search_documents\",\"search_documents_batch\",\"search +_documents_with_context\"],\"document_count\":2,\"chunk_count\":119}],\"success\":true,\"error\":nul +l,\"request_id\":\"b3fa0787-dce3-449b-aff0-51a8d882e81d\"}" +} +], +"structuredContent": { +"data": [ +{ +"name": "test", +"id": "1caf198a-444b-4a89-a235-23e82bc3d68a", +"metadata": { +"description": "col" +}, +"role": 0, +"allowed_tools": [ +"add_document_text", +"delete_collection", +"delete_document", +"list_documents", +"search_documents", +"search_documents_batch", +"search_documents_with_context" +], +"document_count": 2, +"chunk_count": 119 +} +], +"success": true, +"error": null, +"request_id": "b3fa0787-dce3-449b-aff0-51a8d882e81d" +}, +"isError": false +} +} +``` + diff --git a/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/README.md b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/README.md new file mode 100644 index 0000000..562b7bf --- /dev/null +++ b/docs/contracts/agent-builder-mcp/observed-samples/2026-07-13/README.md @@ -0,0 +1,30 @@ +# Agent Builder Tool 호출 관찰 로그 + +- 출처: `MCP_툴_호출_통신_로그_(JSON-RPC) (1).pdf` +- 원본 생성 시각: 2026-07-13 17:10:33 UTC +- 목적: Agent Builder 솔루션이 실제 Tool을 호출하며 남긴 JSON-RPC 흐름을 샘플별로 보존 +- 성격: 관찰 기록. 현재 구현 계약, 목표 계약 또는 자동화 테스트 fixture로 사용하지 않는다. + +원본 PDF는 시크릿 및 인증 header를 `[REDACTED]` 처리했다고 명시한다. 개별 HTTP request/response header 자체는 제공하지 않으므로, 각 문서에는 원본에 기록된 실행 메타데이터와 header 기록 범위를 분리해 표시한다. + +| # | 서버 | Tool | 시각(UTC) | 프레임 | 결과 | 파일 | +|---:|---|---|---|---:|---|---| +| 1 | exa | `web_search_exa` | 16:53:16 | 5 | ok · 1896ms | [01](01-exa-web-search-exa-165316.md) | +| 2 | exa | `web_search_exa` | 16:53:24 | 5 | ok · 1768ms | [02](02-exa-web-search-exa-165324.md) | +| 3 | exa | `web_search_exa` | 16:53:31 | 5 | ok · 2070ms | [03](03-exa-web-search-exa-165331.md) | +| 4 | searchapi | `youtube_search` | 17:02:42 | 4 | ok · 2200ms | [04](04-searchapi-youtube-search.md) | +| 5 | langconnect | `list_collections` | 17:08:46 | 5 | ok · 346ms | [05](05-langconnect-list-collections.md) | + +## JSON 프레임 파일 + +각 요청과 응답은 아래의 독립 JSON 파일로 분리했다. PDF의 시각적 줄바꿈을 제거한 뒤 JSON 문법 검증을 통과한 프레임만 원래 JSON-RPC 구조로 저장했다. + +- [01 Exa](01-exa-web-search-exa-165316-initialize-request.json): `initialize` 요청·응답, `notifications/initialized`, `tools/call` 요청·응답 +- [02 Exa](02-exa-web-search-exa-165324-initialize-request.json): `initialize` 요청·응답, `notifications/initialized`, `tools/call` 요청·응답 +- [03 Exa](03-exa-web-search-exa-165331-initialize-request.json): `initialize` 요청·응답, `notifications/initialized`, `tools/call` 요청·응답 +- [04 SearchAPI](04-searchapi-youtube-search-initialize-request.json): `initialize` 요청·응답, `tools/call` 요청, `tools/call` 응답의 PDF 표시 전사본 +- [05 LangConnect](05-langconnect-list-collections-initialize-request.json): `initialize` 요청·응답, `notifications/initialized`, `tools/call` 요청·응답 + +SearchAPI의 `tools/call` 응답은 원본 PDF가 마지막 22,537바이트를 접어 표시하므로, +[visible transcript](04-searchapi-youtube-search-tools-call-response-visible-transcript.txt)로 저장했다. +끝부분이 없는 전사본이므로 실행 가능한 JSON-RPC 응답이나 `.json` fixture로 취급하지 않는다. diff --git a/docs/contracts/agent-builder-mcp/protocol-v0.2-agentbuilder.md b/docs/contracts/agent-builder-mcp/protocol-v0.2-agentbuilder.md new file mode 100644 index 0000000..dddbda3 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/protocol-v0.2-agentbuilder.md @@ -0,0 +1,44 @@ +# Agent Builder-MCP 이전 non-streaming 계약 v0.2 + +> 상태: v0.3으로 대체됨. 이 문서는 streaming 정책 도입 전 계약 기록이며, 신규 연동에는 사용하지 않는다. + +- 상태: Superseded +- 기준일: 2026-07-16 +- 구현 endpoint: `POST /mcp` +- JSON-RPC: `2.0` +- protocolVersion: `2025-06-18` + +> 이 문서는 교체된 고정 endpoint 시점의 이력이다. 현재 공개 URL과 path 처리는 [v0.3](protocol-v0.3-streaming-policy.md)과 [ADR-0009](../../decisions/ADR-0009-container-handles-public-mcp-path.md)을 따른다. + +이 계약은 Agent Builder에서 받은 JSON-RPC/MCP 호출·응답 예시를 현재 stateless MCP 실행 계층에 반영한 범위다. 서버는 `mcp-session-id`를 요청 correlation에만 사용하며 `Mcp-Session-Id`를 발급하거나 세션 상태를 저장하지 않는다. + +## 범위와 HTTP 정책 + +- 이 버전은 non-streaming 요청에 한정한다. 호출 클라이언트는 `Accept: application/json`을 사용한다. +- 기존 NDJSON streaming 구현과 progress frame은 변경하지 않았다. streaming의 SSE/재개/cancel 계약은 별도 승인 후 반영한다. +- `Content-Type: application/json` 또는 `application/json-rpc`을 사용한다. +- `notifications/initialized`는 Agent Builder가 stateless 서버에 대해 생략할 수 있다. 전송될 경우 표준 lifecycle을 수용하고 HTTP `202 Accepted`와 빈 body를 반환한다. + +## initialize + +Agent Builder는 연결 초기화 시 [요청 예시](examples/agentbuilder-v0.2/initialize-request.json)를 전송한다. 응답은 [응답 예시](examples/agentbuilder-v0.2/initialize-response.json)처럼 `jsonrpc`, `id`, `result.protocolVersion`만 의미 있는 값을 가진다. `serverInfo`와 `capabilities`는 빈 객체다. + +서버는 protocol version으로 `2025-06-18`을 반환한다. 현재 `MCP-Protocol-Version` HTTP 헤더의 수신·검증은 범위 밖이다. + +## notifications/initialized + +초기화 완료 notification의 body는 [예시](examples/agentbuilder-v0.2/initialized-notification.json)와 같다. JSON-RPC notification에는 `id`가 없으며, 서버는 실행 결과 JSON-RPC body를 만들지 않는다. Agent Builder가 stateless 정책으로 notification을 보내지 않아도 Tool 호출은 가능하다. + +## tools/list + +`tools/list` handler는 계속 제공한다. Agent Builder가 사전 등록한 Tool만 사용할 때는 이 호출을 생략할 수 있다. 현재 pagination과 `listChanged` notification은 제공하지 않는다. + +## tools/call + +호출 request는 [예시](examples/agentbuilder-v0.2/tools-call-request.json)처럼 `params.name`과 object 형식의 `params.arguments`를 사용한다. version은 Agent Builder가 보내지 않는다. 서버는 Registry에서 같은 name의 활성 version이 정확히 하나일 때만 이를 해소해 실행한다. 두 개 이상이면 임의 version을 선택하지 않고 오류로 처리한다. + +성공 응답은 [예시](examples/agentbuilder-v0.2/tools-call-success-response.json)처럼 `content`, 선택적 `structuredContent`, `isError: false`를 반환한다. Tool endpoint 실행·timeout·권한 오류는 [예시](examples/agentbuilder-v0.2/tools-call-execution-error-response.json)처럼 HTTP/JSON-RPC transport error 대신 `result.isError: true`로 반환한다. 잘못된 JSON-RPC envelope, 알 수 없는 method, 잘못된 name/arguments는 기존 JSON-RPC `error`를 사용한다. + +## 호환성 메모 + +- UID execution key 검토안은 [ADR-0005](../../decisions/ADR-0005-standard-tool-name.md)에서 폐기되었다. 신규 계약은 표준 MCP `name`을 실행 식별자로 사용한다. diff --git a/docs/contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md b/docs/contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md new file mode 100644 index 0000000..909531a --- /dev/null +++ b/docs/contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md @@ -0,0 +1,82 @@ +# Agent Builder-MCP 동기 JSON 계약 v0.3 + +- 상태: Implemented +- 기준일: 2026-07-16 +- 공개 endpoint: `POST https://{mcpHost}{publicPath}` +- 컨테이너 endpoint: 공개 URL과 동일한 `POST {publicPath}` +- JSON-RPC: `2.0` +- protocolVersion: `2025-06-18` + +이 계약의 현재 구현은 stateless MCP 실행 계층의 transport를 동기 JSON으로 고정한다. 현재 in-memory snapshot의 표준 Tool name metadata를 조회해 확정된 endpoint로 POST하며, `Mcp-Session-Id`는 lifecycle correlation 값일 뿐 서버는 initialize 성공 시 이를 발급하지만 대화·readiness 상태를 저장하지 않는다. + +한 환경은 공개 host를 공유하지만 path마다 독립된 MCP Deployment와 Tool Service에 연결된다. Agent Builder는 각 공개 URL을 별도 MCP로 등록하고 initialize한다. URL 사이에는 session ID, Tool 목록, lifecycle 상태를 공유하지 않는다. Route는 path를 바꾸지 않으며 컨테이너가 같은 path를 처리한다. 이 매핑은 [ADR-0009](../../decisions/ADR-0009-container-handles-public-mcp-path.md)이 정본이며 JSON-RPC payload에는 영향을 주지 않는다. + +## HTTP 선택 정책 + +- Agent Builder는 `Accept: application/json, text/event-stream`을 보낸다. +- 서버는 항상 `Content-Type: application/json`과 단일 JSON-RPC response를 반환한다. +- `Accept`는 수용 가능 형식의 선언이며, `text/event-stream`이 포함되어도 응답 transport를 바꾸지 않는다. +- 독립적인 server-push SSE channel은 제공하지 않으므로 공개 endpoint의 `GET`은 `405 Method Not Allowed`다. +- `initialize` 요청에는 `MCP-Protocol-Version` header를 요구하지 않는다. +- `initialize` 이후 `notifications/initialized`, `tools/list`, `tools/call` 요청에는 정확히 `MCP-Protocol-Version: 2025-06-18`이 필수다. `version` 등 임의 header는 대체하지 않는다. header가 없거나 지원하지 않는 값이면 server는 JSON-RPC body 대신 HTTP `400 Bad Request`와 `error`, `message`, `supportedVersions`, `guid`를 가진 JSON 오류 body를 반환한다. + +## 호출자 식별 header + +`MCP-Protocol-Version` 외에 Agent Builder가 보내는 header는 다섯 개이며 **모두 선택값**이다. + +| header | 형식 | 서버 동작 | +|---|---|---| +| `guid` | UUID | 없으면 서버가 생성한다. 응답 header와 오류 body에 되돌려준다 | +| `x-request-id` | 안전 문자 1~128자 | 없으면 서버가 생성한다. 응답 header에 되돌려준다 | +| `mcp-session-id` | 안전 문자 1~128자 | initialize lifecycle 상관 값. 서버는 저장하지 않는다 | +| `employee-no` | 암호화된 사원번호 | 해석하지 않는다 | +| `virtual-employee-no` | 암호화된 가상사원번호 | 해석하지 않는다 | + +사원 식별자 둘은 **불투명 값**이다. MCP는 복호화·검증·저장하지 않고 Tool Service로 그대로 전달한다. +값의 의미는 보지 않되, 개행이나 공백이 섞여 downstream 요청 header를 조작하는 것은 거부한다 +(출력 가능 문자 1~2048자가 아니면 `-32600`). + +암호화된 값이라도 **로그에 남기지 않는다.** 로그에 나가는 상관 값은 `guid`와 `x-request-id`뿐이다. + +## initialize와 notification + +`initialize`는 [v0.2 요청 예시](examples/agentbuilder-v0.2/initialize-request.json)를 그대로 사용하며, 응답은 [v0.3 응답 예시](examples/agentbuilder-v0.3/initialize-response.json)처럼 원 요청 `id`, `protocolVersion: 2025-06-18`, `serverInfo(name/title/version)`, `capabilities.tools.listChanged: false`를 반환한다. HTTP response header에는 새 UUID `Mcp-Session-Id`가 포함된다. Agent Builder는 응답 version을 이후 모든 HTTP 요청의 `MCP-Protocol-Version` header에 사용하고, session ID를 `notifications/initialized` 및 이후 Tool 요청의 correlation header로 보낸다. MCP 2025-06-18 lifecycle에 따라 Agent Builder는 `notifications/initialized`를 반드시 보내고 두 header를 포함한다. 서버는 notification을 HTTP `202 Accepted`와 빈 body로 수용하되 stateless 원칙상 수신 여부를 저장하거나 이후 요청을 차단하는 readiness gate로 사용하지 않는다. + +## tools/list + +`tools/list`는 `result.tools`에 현재 snapshot의 공개 Tool 필드(`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`)를 반환한다. `_meta`의 version, endpoint, HTTP method, timeout, cache 설정은 실행·운영 metadata이므로 MCP 공개 응답에 포함하지 않는다. + +현재 `tools/call`은 `structuredContent`를 반환하거나 Tool 응답을 `outputSchema`로 검증하지 않는다. 따라서 `outputSchema`를 가진 Tool 정의를 그대로 노출하는 동작은 현재 코드의 사실이지만 MCP 2025-06-18의 구조화 출력 계약을 완전히 충족하지 않는다. 운영 Tool은 구조화 출력 지원이 도입되기 전까지 `outputSchema`를 생략해야 한다. + +원천은 profile이 정한다. local은 Tool Service 매니페스트를 먼저 조회하고 최초 실패 시 `config/local-core-tools-manifest-sample-v1.json` fallback을 사용한다(파일이 곧 목록이므로 여기에 Tool 이름을 옮겨 적지 않는다). 운영은 설정된 Tool Service 매니페스트뿐이다. + +## 동기 Tool 호출 + +기본 Tool 호출은 [요청 예시](examples/agentbuilder-v0.3/tools-call-request.json)처럼 `params.name`과 object `params.arguments`를 사용한다. name은 `tools/list`와 실행 사이의 유일한 식별자다. MCP는 snapshot metadata에서 endpoint를 확정하고 arguments 전체를 JSON body로 전달한다. 성공 및 Tool 실행 실패는 각각 [성공 응답](examples/agentbuilder-v0.3/tools-call-success-response.json), [실행 실패 응답](examples/agentbuilder-v0.3/tools-call-execution-error-response.json)처럼 `application/json` JSON-RPC response로 반환한다. + +성공 응답은 Tool의 plain text를 `result.content[0].text`, 소요 시간(ms)을 `result.content[0]._meta.searchTime`, 성공 여부를 `result.isError: false`에 넣는다. JSON object/array 응답은 compact JSON 문자열로 `text`에 보존하며, outer JSON serializer가 올바른 quote escaping을 수행한다. 실행·timeout·권한 실패는 `result.isError: true`이며, JSON-RPC envelope/params/method 오류는 기존 JSON-RPC `error`다. Registry의 `inputSchema`는 모든 `tools/call`에서 Tool 호출 전에 검증한다. + +## tools/call 성공·오류 응답 기준 + +Agent Builder는 HTTP 상태만으로 성공 여부를 판단하지 않고 JSON-RPC body의 최상위 `result` 또는 `error`를 확인해야 한다. 일반적인 JSON-RPC 요청 오류는 HTTP `200 OK`와 함께 최상위 `error`로 반환될 수 있다. `-32602`의 `error.message`는 `Invalid params: <상세 원인>` 형식이며, 예를 들어 필수 `query`가 없으면 `Invalid params: 'query' is required`를 반환한다. 선택적인 `error.data`에는 `guid`와 상세 원인을 추가로 담을 수 있다. 단, `MCP-Protocol-Version` 누락·미지원처럼 HTTP transport 단계에서 거부된 요청은 HTTP `400 Bad Request`다. + +| 상황 | HTTP 상태 | JSON-RPC body | `isError` | 현재 구현의 처리 주체 | +|---|---:|---|---|---| +| Tool 정상 완료 | 200 | `result.content` | 반드시 `false` | `ToolsCallHandler` | +| Tool Service timeout, upstream 4xx/5xx, downstream 권한 거부 | 200 | `result.content` | 반드시 `true` | `ToolsCallHandler` | +| Tool이 실행된 뒤 업무 검증·업무 규칙으로 실패 | 200 | `result.content` | 반드시 `true` | Tool Service 또는 실행 계층 | +| JSON 문법 오류 | 200 | 최상위 `error` (`-32700`) | 없음 | `McpExceptionHandler` | +| JSON-RPC envelope 오류 | 200 | 최상위 `error` (`-32600`) | 없음 | `JsonRpcRequestParser` | +| 알 수 없는 MCP method 또는 Tool | 200 | 최상위 `error` (`-32601` 또는 Tool 조회 오류) | 없음 | method/registry 계층 | +| `params.name` 누락, `params.arguments` 형식 오류, 공개된 inputSchema의 필수 값 누락 | 200 | 최상위 `error` (`-32602`) | 없음 | Adapter/parameter/schema validator | +| 서버 설정·Registry 장애 등 서버가 Tool 호출을 시작할 수 없는 경우 | 200 | 최상위 `error` (`-32603` 또는 서버 정의 오류) | 없음 | transport/execute 계층 | +| `MCP-Protocol-Version` 누락 또는 미지원 | 400 | transport 오류 body | 없음 | `McpProtocolVersionValidator` | + +모든 routing은 공통 `name`/`arguments` 형식과 선택된 Tool의 `inputSchema`를 실행 전에 검증한다. Tool Service가 반환한 HTTP 400은 검증을 통과해 Tool 실행을 시작한 뒤의 실패이므로 `result.isError: true`로 반환한다. + +실행 가능한 응답 형태는 [성공 예시](examples/agentbuilder-v0.3/tools-call-success-response.json), [Tool 실행 실패 예시](examples/agentbuilder-v0.3/tools-call-execution-error-response.json), [잘못된 인자 예시](examples/agentbuilder-v0.3/tools-call-invalid-params-response.json)를 따른다. + +## 호환성 메모 + +- v0.2의 일반 JSON 요청·응답 형식은 그대로 호환된다. +- Agent Builder의 기존 `Accept: application/json, text/event-stream` header는 계속 수용한다. diff --git a/docs/contracts/agent-builder-mcp/protocol-v1-agreement-baseline.md b/docs/contracts/agent-builder-mcp/protocol-v1-agreement-baseline.md new file mode 100644 index 0000000..c0c74f8 --- /dev/null +++ b/docs/contracts/agent-builder-mcp/protocol-v1-agreement-baseline.md @@ -0,0 +1,56 @@ +# Agent Builder-MCP 목표 계약 v1 합의 기준선 + +- 상태: Partial Agreement +- 합의 기준일: 2026-07-10 +- 목적: 확정된 목표 제약만 기록하고 미확정 payload의 추측 구현을 방지 + +## Normative 합의 사항 + +### 책임 경계 + +- Agent Builder가 Tool을 선택한다. +- MCP Server는 요청에 명시된 Tool만 검증·실행한다. +- MCP Server는 stateless하며 `mcp-session-id`를 대화 상태로 사용하지 않는다. + +### Tool 노출과 호출 단위 + +- Agent Builder가 LLM에 노출하는 Tool은 최대 50개다. +- Agent Builder-MCP 목표 호출은 요청 하나당 Tool 하나다. + +### Tool 식별 + +- 표준 MCP Tool `name`을 `tools/list`와 `tools/call` 사이의 시스템 간 실행 key로 사용한다. +- Tool Service가 전체 MCP 범위에서 고유한 namespaced name을 선언하고 MCP Server는 이를 재작성하지 않는다. +- Agent Builder UID는 Agent Builder 내부 관리에만 사용하고 MCP wire contract에 포함하지 않는다. + +### 실행 가드레일 + +- 전체 상호작용 상한은 300초다. +- MCP Server는 3만 자 기준으로 원문 응답을 임의 절단하지 않는다. +- idempotency가 확인되지 않은 write/update Tool은 자동 retry하지 않는다. + +## 아직 Normative가 아닌 항목 + +다음 항목은 목표 v1의 일부로 구현하면 안 된다. + +- 최종 method 이름 (`tool/call`, `tools/call` 또는 별도 vendor method) +- 최종 Tool Call parameter와 result field 이름 +- `initialize` 전체 request/response +- Tool name 변경·폐기 시 rolling 호환 기간과 Agent Builder 내부 mapping lifecycle +- 사원 식별자 header를 언젠가 필수로 승격할지 여부 (현재 계약에서는 선택값이며 MCP는 검증하지 않는다) +- HTTP status와 JSON-RPC/업무 error code 매핑 +- read retry 조건과 idempotency key +- 300초 timeout budget과 cancellation protocol +- 대용량 response의 최대 크기와 continuation 방식 +- streaming media type과 framing + +## 요청·응답 규격 처리 원칙 + +목표 v1의 확정 JSON 예시는 아직 제공하지 않는다. 미확정 field를 임의로 채운 JSON은 사실상 새로운 계약 결정이 되기 때문이다. + +Agent Builder의 전체 payload와 위 미확정 항목이 승인되면 다음 순서로 갱신한다. + +1. 이 문서 상태를 `Approved`로 변경한다. +2. 목표 v1 JSON example과 schema를 추가한다. +3. 관련 ADR을 추가하거나 기존 ADR의 구현 보류 조건을 해제한다. +4. 코드와 테스트 변경을 별도 작업으로 수행한다. diff --git a/docs/contracts/tool-service-mcp/README.md b/docs/contracts/tool-service-mcp/README.md new file mode 100644 index 0000000..fcfdadd --- /dev/null +++ b/docs/contracts/tool-service-mcp/README.md @@ -0,0 +1,39 @@ +# Tool Service-MCP 계약 문서 + +이 디렉터리는 Tool Service와 MCP Server 사이의 metadata 조회·실행 계약을 관리한다. + +```text +Agent Builder ──[agent-builder-mcp 계약]──▶ MCP Server ──[tool-service-mcp 계약]──▶ Tool Service +``` + +| 문서 | 상태 | 용도 | +|---|---|---| +| [protocol-v0.2-bundle-discovery.md](protocol-v0.2-bundle-discovery.md) | Implemented | Tool Service Bundle의 매니페스트 조회·실행 계약. 구현은 N개 Bundle을 지원하지만 운영 배포는 1개로 고정 | +| [TEMP-tool-list-loading-guide.md](TEMP-tool-list-loading-guide.md) | Temporary | Tool 개발 파트가 현재 최초 적재·memory snapshot·`tools/list` 변환 흐름을 이해하기 위한 안내 | + +push 등록 방식(v0.1)은 채택하지 않았다. 그 이유는 +[v0.2 §2](protocol-v0.2-bundle-discovery.md#2-왜-조회-방식인가-왜-기동-시-1회가-아닌가)에 있다. + +## 현재 원칙 + +- 운영 Tool metadata의 유일한 원천은 각 Tool Service의 매니페스트다. +- `local` profile은 Tool Service 매니페스트를 먼저 조회하고, 최초 실패 시 `config/local-core-tools-manifest-sample-v1.json` fallback을 사용한다. +- 표준 MCP `name`이 `tools/list`와 `tools/call`의 실행 식별자다. Agent Builder UID는 이 계약에 포함하지 않는다. +- Tool Service는 표준 MCP `name`을 선언한다. MCP는 자기 Bundle 안에서 형식·접두사·중복을 검증하며, 서로 다른 MCP 배포 간 전역 유일성은 Tool Service·플랫폼의 변경 절차로 보장한다. +- MCP는 요청 경로에서 in-memory snapshot만 읽는다. Redis는 선택적인 공유 last-good cache다. +- 조회 실패는 Tool 삭제가 아니다. 성공한 매니페스트가 Tool을 제외했을 때만 삭제를 반영한다. +- 불완전한 aggregate, 중복 name, 총량 상한 초과는 현재 snapshot을 교체하지 않는다. + +## 예제와 검증 + +[examples/bundle-v0.2](examples/bundle-v0.2/)의 매니페스트, MCP 설정, Actuator 상태 응답을 계약 테스트가 직접 읽는다. +예제와 구현은 같은 변경에서 함께 수정한다. + +운영 적용 전에 Tool 개발 파트와 다음 항목을 확정한다. + +1. MCP → Tool 방향 NetworkPolicy와 매니페스트 인증 방식 +2. Tool name 변경·폐기 시 rolling 호환 기간 +3. `namePrefix`, Tool 수, 매니페스트 크기 상한 +4. Tool Service별 timeout과 권한 scope + +상세 필드와 장애 처리는 [v0.2 계약](protocol-v0.2-bundle-discovery.md)을 따른다. diff --git a/docs/contracts/tool-service-mcp/TEMP-tool-list-loading-guide.md b/docs/contracts/tool-service-mcp/TEMP-tool-list-loading-guide.md new file mode 100644 index 0000000..78af2ec --- /dev/null +++ b/docs/contracts/tool-service-mcp/TEMP-tool-list-loading-guide.md @@ -0,0 +1,227 @@ +# 임시 안내: Tool 목록 최초 적재와 `tools/list` 노출 흐름 + +> 상태: **임시 학습 문서** · 기준: 현재 MCP 서버 구현 · 대상: Tool Service 개발 파트 +> +> 이 문서는 현재 동작을 이해하기 위한 안내다. 외부 wire 계약의 정본은 +> [Tool Service-MCP Bundle 조회 계약 v0.2](protocol-v0.2-bundle-discovery.md)다. + +## 먼저 구분할 것 + +Tool Service가 MCP 표준 `tools/list`를 직접 구현하는 구조가 아니다. Tool Service는 아래의 내부 +**매니페스트 endpoint**를 제공하고, MCP Server가 이를 읽어 Agent Builder용 표준 `tools/list` 응답으로 +변환한다. + +```text +Tool Service -- GET /tool-manifest --> MCP Server -- JSON-RPC tools/list --> Agent Builder +``` + +현재 운영 배포는 MCP 하나가 Tool Service Bundle 하나를 본다. 구현은 호환 목적으로 여러 Bundle의 +병합도 지원하지만, Tool 개발 파트는 자기 Bundle 하나의 매니페스트만 제공하면 된다. + +## 1. 최초 적재는 구현되어 있는가? + +**구현되어 있다.** Spring 애플리케이션이 준비되면 `ToolRegistryRefreshScheduler.preload()`가 실행된다. + +```text +ApplicationReadyEvent + -> 선택 Redis snapshot warm start (있으면 memory에 임시 적재) + -> Tool Service manifest 즉시 조회 + -> 검증 성공한 전체 Tool 목록으로 memory snapshot 교체 + -> 선택 Redis cache 저장 + -> readiness 판단 가능 +``` + +Redis는 선택 cache일 뿐이다. Redis가 없거나 실패해도 Tool Service 매니페스트 조회가 성공하면 정상 +기동한다. 반대로 최초 조회와 선택 cache 모두 실패하면 애플리케이션 프로세스는 살아 있어도 usable +Tool 목록이 없으므로 readiness는 DOWN이다. 다음 주기 조회에서 자동 재시도한다. + +`tools/list` 요청이 기동 preload보다 먼저 들어와 memory snapshot이 비어 있으면, 요청 경로도 원천을 +한 번 직접 조회해 cold start 공백을 메운다. + +## 2. Tool Service에서 memory까지의 처리 순서 + +```text +ToolBundleRegistryClient.fetchTools() + -> ToolBundleDiscovery.discoverAll() + -> GET {manifestUrl} + -> bundleId / tools[] / Tool 필수 필드 검증 + -> ToolMetadata 생성 (endpoint는 MCP 배포 설정의 baseEndpoint 사용) + -> enabled=false Tool 제외 + -> immutable List를 AtomicReference snapshot에 저장 +``` + +실제 memory 저장소는 `ToolRegistryService`의 `AtomicReference>`다. + +- 매니페스트 조회·검증에 **성공했을 때만** 새 immutable 목록으로 통째로 교체한다. +- HTTP 오류, timeout, JSON 오류, 필수 필드 누락, 이름 규칙 위반은 기존 snapshot을 비우지 않는다. +- Tool 하나만 걸러서 부분 반영하지 않는다. 매니페스트 하나가 잘못되면 해당 Bundle 전체를 거부한다. +- 현재 운영은 Bundle 하나지만, 구현상 여러 Bundle이면 모두 사용 가능한 성공본이 있을 때만 하나의 snapshot을 교체한다. +- 주기 refresh가 겹치면 single-flight로 하나의 원천 조회를 공유한다. + +## 3. Agent Builder의 `tools/list` 요청은 어떻게 처리되는가? + +Agent Builder는 공개 `POST https://{mcpHost}{publicPath}`로 JSON-RPC 요청을 보낸다. OpenShift Route는 +해당 path의 MCP Service만 선택하고, 컨테이너가 같은 `POST {publicPath}`를 직접 처리한다. + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} +} +``` + +처리 경로는 다음과 같다. + +```text +McpController + -> McpMethodHandlerRegistry + -> ToolsListHandler + -> ToolRegistryService.listTools() + -> in-memory snapshot 읽기 + -> MCP SDK ListToolsResult 변환 + -> JSON-RPC result.tools 반환 +``` + +memory snapshot이 이미 있으면 `tools/list`는 Tool Service나 Redis를 호출하지 않는다. 따라서 Tool +Service가 잠시 느리거나 Redis가 장애여도 이미 적재한 목록은 바로 반환한다. + +`ToolsListHandler`는 매니페스트 Tool 정의의 공개 필드만 MCP Tool로 만든다. `_meta` 안의 +`version`, `timeoutMillis`, `enabled`와 MCP 내부의 `endpoint`는 절대 Agent Builder에 노출하지 않는다. + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + { + "name": "processing.contract.inquiry", + "title": "계약 조회", + "description": "계약번호로 계약의 기본 정보를 조회합니다.", + "inputSchema": { + "type": "object", + "properties": { + "contractNo": { "type": "string" } + }, + "required": ["contractNo"] + }, + "annotations": { + "readOnlyHint": true + } + } + ] + } +} +``` + +정확한 Agent Builder 응답 fixture는 +[tools-list-response.json](../agent-builder-mcp/examples/agentbuilder-v0.3/tools-list-response.json)을 따른다. + +## 4. Tool Service가 구현할 매니페스트 endpoint + +Tool Service는 MCP 배포 설정에 등록된 `manifestUrl`에 대해 다음을 반환한다. + +```text +GET /tool-manifest +Accept: application/json + +200 OK +Content-Type: application/json +``` + +이 요청은 사용자 Tool 실행이 아니라 MCP의 배경 metadata 갱신이다. 따라서 `guid`, 사원 식별자, +`Mcp-Session-Id` 같은 요청 상관·사용자 header를 기대하면 안 된다. + +현재 구현은 conditional GET을 보내지 않으므로 Tool Service는 우선 항상 `200 OK`와 전체 JSON을 +반환하면 된다. `304 Not Modified`와 ETag는 계약상 선택 사항이지만 현재 MCP 구현 범위가 아니다. + +### 응답 규칙 + +```json +{ + "bundleId": "insurance-processing", + "revision": "2026-08-03T01", + "tools": [ + { + "name": "processing.contract.inquiry", + "title": "계약 조회", + "description": "계약번호로 계약의 기본 정보를 조회합니다.", + "inputSchema": { + "type": "object", + "properties": { + "contractNo": { + "type": "string", + "description": "조회할 계약번호입니다.", + "minLength": 1 + } + }, + "required": ["contractNo"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "_meta": { + "version": "1.0.0", + "timeoutMillis": 3000, + "enabled": true + } + } + ] +} +``` + +| 항목 | Tool Service 규칙 | MCP 처리 | +|---|---|---| +| `bundleId` | 필수. MCP 배포 설정의 Bundle id와 정확히 일치 | 다르면 Bundle 전체 거부 | +| `revision` | 선택. 변경 식별·운영 진단용 | 현재 호출 대상이나 공개 응답에는 사용하지 않음 | +| `tools` | 필수. 이 Bundle의 **전체 상태**를 배열로 반환 | 정상 빈 배열은 “노출 Tool 없음”으로 채택 | +| `name` | 필수. `[A-Za-z0-9_./-]{1,64}` 및 설정 `namePrefix`로 시작 | 위반 시 Bundle 전체 거부 | +| `description` | 필수. Agent Builder가 Tool 선택에 사용할 설명 | 그대로 `tools/list`에 공개 | +| `inputSchema` | 필수 JSON Schema object | 그대로 공개하고 `tools/call` 전에 검증 | +| `title`, `annotations` | 선택 공개 정보 | 있으면 `tools/list`에 공개 | +| `_meta.version` | 필수 | 내부 metadata로만 사용, 공개하지 않음 | +| `_meta.timeoutMillis` | 선택 | 설정 상한 이하로 제한, 공개하지 않음 | +| `_meta.enabled` | 선택, 기본 `true` | `false`면 memory snapshot과 `tools/list`에서 제외 | +| `outputSchema` | 현재 운영에서는 생략 | `structuredContent` 미지원 상태라 선언하지 않음 | + +`baseEndpoint`, Tool 실행 URL, credential은 매니페스트에 넣지 않는다. MCP가 실제 호출할 주소는 +배포 설정의 `baseEndpoint`에서만 결정한다. 매니페스트 안의 `endpoint` 성격 필드는 있어도 읽지 않는다. + +## 5. Tool Service가 알아야 할 실패 동작 + +| Tool Service 매니페스트 결과 | MCP 동작 | +|---|---| +| `200` + 전체 검증 통과 | 새 목록을 memory에 교체하고 다음 `tools/list`부터 노출 | +| `200` + JSON/필수 필드/이름 오류 | 직전 성공 목록 유지. 첫 기동이면 목록을 만들지 못함 | +| timeout, 연결 실패, 4xx/5xx | 직전 성공 목록 유지. 첫 기동이면 readiness DOWN | +| 정상 `tools: []` | 빈 목록을 정상 전체 상태로 채택 | +| Tool 하나만 제거한 정상 전체 manifest | 다음 갱신에 그 Tool도 목록에서 제거 | + +따라서 Tool Service는 manifest 응답을 부분 목록이나 증분 변경으로 보내면 안 된다. 한 번의 `200` 응답은 +그 시점에 노출할 Tool의 완전한 목록이어야 한다. + +## Tool Service 구현 체크리스트 + +1. `GET /tool-manifest`를 MCP Server namespace에서만 접근 가능하게 제공한다. +2. `bundleId`가 배포 설정의 Bundle id와 정확히 일치하는지 배포 전에 함께 확인한다. +3. 모든 Tool에 고유한 표준 `name`, 비어 있지 않은 `description`, object 형태의 `inputSchema`, `_meta.version`을 넣는다. +4. Tool을 숨기려면 `_meta.enabled: false`를 쓰거나 정상 전체 목록에서 제거한다. 둘의 변경 반영 시점은 다음 refresh다. +5. 실행 주소·credential·개인정보·업무 payload를 매니페스트에 넣지 않는다. +6. Tool 자체 실행 endpoint는 별도로 `POST {baseEndpoint}/{toolName}`을 구현한다. manifest endpoint는 실행 endpoint가 아니다. + +## 확인한 구현·테스트 + +- 최초 preload·주기 refresh: `ToolRegistryRefreshScheduler` +- in-memory snapshot·실패 fallback: `ToolRegistryService` +- HTTP 매니페스트 조회·필드 검증: `ToolBundleDiscovery` +- `tools/list` 공개 필드 변환·`_meta` 제거: `ToolsListHandler` +- 회귀 테스트: `ToolRegistryServiceTest`, `ToolBundleDiscoveryTest`, `ToolsListHandlerTest` + +자세한 field 정의와 실행 계약은 [v0.2 계약](protocol-v0.2-bundle-discovery.md), 실제 manifest 전체 예시는 +[manifest-response.json](examples/bundle-v0.2/manifest-response.json)을 참고한다. diff --git a/docs/contracts/tool-service-mcp/examples/bundle-v0.2/bundle-status-response.json b/docs/contracts/tool-service-mcp/examples/bundle-v0.2/bundle-status-response.json new file mode 100644 index 0000000..2e91897 --- /dev/null +++ b/docs/contracts/tool-service-mcp/examples/bundle-v0.2/bundle-status-response.json @@ -0,0 +1,54 @@ +{ + "bundles": [ + { + "bundleId": "insurance-processing", + "enabled": true, + "status": "healthy", + "revision": "sha256:9f2c4a17b83e5d06c1f9a2e7b45d8c30ff1a6b92e4c7d5083a1b6e9f2c4d7a850", + "toolCount": 2, + "consecutiveFailures": 0, + "lastSuccessAt": "2026-07-29T02:29:45Z", + "lastFailureReason": null + }, + { + "bundleId": "insurance-corebanking", + "enabled": true, + "status": "degraded", + "revision": "sha256:1d70e6b4c2a89f35e0b7d4816c3a92f5088b1e7d6a4c93520fb8e1d7a6c40395", + "toolCount": 5, + "consecutiveFailures": 1, + "lastSuccessAt": "2026-07-29T02:29:15Z", + "lastFailureReason": "ResourceAccessException" + }, + { + "bundleId": "insurance-payment", + "enabled": true, + "status": "degraded", + "revision": "sha256:7e4c81b0f90f4a2c31e7d1086aa9cd31b2f14403e1f0c6633ca2b424e6d4a812", + "toolCount": 3, + "consecutiveFailures": 4, + "lastSuccessAt": "2026-07-29T02:10:00Z", + "lastFailureReason": "ResourceAccessException" + }, + { + "bundleId": "insurance-claim", + "enabled": true, + "status": "unreachable", + "revision": null, + "toolCount": 0, + "consecutiveFailures": 2, + "lastSuccessAt": null, + "lastFailureReason": "IllegalStateException" + }, + { + "bundleId": "insurance-channel", + "enabled": false, + "status": "disabled", + "revision": null, + "toolCount": 0, + "consecutiveFailures": 0, + "lastSuccessAt": null, + "lastFailureReason": null + } + ] +} diff --git a/docs/contracts/tool-service-mcp/examples/bundle-v0.2/manifest-response.json b/docs/contracts/tool-service-mcp/examples/bundle-v0.2/manifest-response.json new file mode 100644 index 0000000..0d9b501 --- /dev/null +++ b/docs/contracts/tool-service-mcp/examples/bundle-v0.2/manifest-response.json @@ -0,0 +1,94 @@ +{ + "bundleId": "insurance-processing", + "revision": "sha256:9f2c4a17b83e5d06c1f9a2e7b45d8c30ff1a6b92e4c7d5083a1b6e9f2c4d7a850", + "tools": [ + { + "name": "processing.contract.inquiry", + "title": "계약 조회", + "description": "계약번호로 계약의 기본 정보를 조회합니다. 사용자가 특정 계약의 상태, 보험료, 계약일을 물어볼 때 사용합니다. 테스트 전용이며 실제 고객 계약 데이터는 처리하지 않습니다.", + "inputSchema": { + "type": "object", + "properties": { + "contractNo": { + "type": "string", + "description": "조회할 계약번호입니다.", + "minLength": 1 + } + }, + "required": ["contractNo"], + "additionalProperties": false + }, + "annotations": { + "title": "계약 조회", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "_meta": { + "version": "1.2.0", + "timeoutMillis": 3000, + "enabled": true + } + }, + { + "name": "processing.payment.history", + "title": "수납 이력 조회", + "description": "계약번호로 수납 이력을 조회합니다. 사용자가 납입 내역이나 미납 여부를 물어볼 때 사용합니다.", + "inputSchema": { + "type": "object", + "properties": { + "contractNo": { + "type": "string", + "description": "조회할 계약번호입니다.", + "minLength": 1 + }, + "months": { + "type": "integer", + "description": "조회할 최근 개월 수입니다.", + "minimum": 1, + "maximum": 36 + } + }, + "required": ["contractNo"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "_meta": { + "version": "1.0.1", + "timeoutMillis": 5000, + "enabled": true + } + }, + { + "name": "processing.notice.send", + "title": "안내 발송", + "description": "계약자에게 안내 메시지를 발송합니다. 사용자가 명시적으로 발송을 요청한 경우에만 사용합니다.", + "inputSchema": { + "type": "object", + "properties": { + "contractNo": { "type": "string", "minLength": 1 }, + "template": { "type": "string", "enum": ["PAYMENT_DUE", "CONTRACT_EXPIRY"] } + }, + "required": ["contractNo", "template"], + "additionalProperties": false + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "_meta": { + "version": "0.9.0", + "timeoutMillis": 10000, + "enabled": false + } + } + ] +} diff --git a/docs/contracts/tool-service-mcp/examples/bundle-v0.2/mcp-bundle-config.yaml b/docs/contracts/tool-service-mcp/examples/bundle-v0.2/mcp-bundle-config.yaml new file mode 100644 index 0000000..2b58699 --- /dev/null +++ b/docs/contracts/tool-service-mcp/examples/bundle-v0.2/mcp-bundle-config.yaml @@ -0,0 +1,49 @@ +# MCP Server의 bundle 조회 설정 예시 (protocol-v0.2-bundle-discovery.md 3절) +# +# 이 파일은 계약 예시이며 실제 적용 설정이 아니다. +# 운영에서는 ConfigMap으로 주입하고 MCP Server마다 다른 bundle 목록을 갖는다. +# +# 키 이름은 구현된 McpProperties와 1:1로 맞춰 두었다. Spring relaxed binding이 +# camelCase와 kebab-case를 모두 받으므로 이 문서는 읽기 쉬운 camelCase를 쓴다. + +mcp: + # 이 MCP Server의 식별자. Redis key namespace에 사용한다. + identity: mcp-insurance-core + + registry: + # 조회 주기와 첫 scheduled refresh 쏠림을 줄이는 지연 jitter. 기동 preload는 즉시 실행한다. + refreshIntervalSeconds: 30 + refreshJitterSeconds: 5 + + discovery: + # 운영 profile에서는 true이고 아래 Tool Service 매니페스트만 원천으로 사용한다. + # false는 local profile의 테스트 JSON에만 사용한다. + enabled: true + + connectTimeoutMillis: 1000 + readTimeoutMillis: 3000 + + # 상한. 초과 시 처리는 계약 7절을 따른다. + maxToolsPerBundle: 100 + maxToolsTotal: 200 + maxManifestBytes: 1048576 + + # 매니페스트가 선언한 Tool timeout의 상한. 초과분은 절삭한다. + # Tool이 과도한 timeout을 선언해 MCP 스레드를 점유하는 것을 막는다. + maxToolTimeoutMillis: 30000 + + # 스키마는 N개를 허용하지만 운영 배포에서는 항상 한 항목이다. + # MCP 배포 하나가 Tool Service 하나만 보기 때문이다(ADR-0007). + # 대상을 늘리려면 이 목록이 아니라 MCP 배포를 하나 더 만든다. + bundles: + - id: insurance-processing + # 매니페스트 조회 주소 (MCP -> Tool) + manifestUrl: http://tool-processing.ax-hub.svc.cluster.local:8080/tool-manifest + # Tool 실행 주소. Pod IP가 아니라 Service URL을 사용한다. + # 매니페스트가 이 값을 바꿀 수 없다. 이것이 조회 방식의 보안 기반이다. + baseEndpoint: http://tool-processing.ax-hub.svc.cluster.local:8080/mcp + # Tool Service가 선언한 표준 MCP name이 따라야 할 접두사. + # 업무 단위이며 중요도 등급을 넣지 않는다. 등급이 이름에 들어가면 + # Tool 재분류가 Tool name 변경이 되어 Agent Builder 재등록을 부른다. + namePrefix: "processing." + enabled: true diff --git a/docs/contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md b/docs/contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md new file mode 100644 index 0000000..8836e1c --- /dev/null +++ b/docs/contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md @@ -0,0 +1,349 @@ +# Tool Service-MCP Bundle 조회 계약 v0.2 + +- 상태: **Implemented** (MCP 서버 측 구현 완료, Tool Service 측 합의 대기) +- 기준일: 2026-07-30 +- 대체 대상: push 등록 방식(v0.1). 채택하지 않은 이유는 §2 +- 조회 endpoint: `GET {manifestUrl}` — Tool Service가 제공 +- 실행 endpoint: `POST {baseEndpoint}/{toolName}` — 현재 구현 + +## 1. 계약 범위와 원칙 + +Tool Service는 여러 Tool을 함께 배포하는 하나의 프로젝트이며, 이 계약에서 **bundle**이라 부른다. +MCP Server는 자기 설정에 선언된 bundle의 매니페스트를 **주기적으로 조회**해 Tool 목록을 구성한다. + +| 원칙 | 내용 | +|---|---| +| MCP가 가져온다 | Tool Service는 매니페스트를 제공만 한다. MCP에 등록 요청을 보내지 않는다 | +| 조회 대상은 설정이 정한다 | 어떤 bundle이 이 MCP에 속하는지는 배포 시점 YAML로 확정된다 | +| **라우팅 주소는 설정이 소유한다** | 호출 대상 주소는 MCP 설정에서만 온다. 매니페스트가 바꿀 수 없다 | +| 매니페스트는 전체 상태 | 응답은 그 bundle의 Tool 전체 목록이다. 증분 없음 | +| 조회 성공이 생존 신호 | 별도 heartbeat·TTL 장치가 없다 | +| bundle 단위 조회 격리 | 한 bundle의 조회 실패가 다른 bundle의 조회를 중단시키지 않는다 | +| aggregate는 전부 아니면 전무 | 단, 직전 성공본조차 없는 bundle이 하나라도 있으면 카탈로그 전체를 교체하지 않는다 | + +세 번째 원칙이 이 계약의 보안 기반이다. 매니페스트는 **무엇을 노출하는가**만 말하고 +**어디로 호출할 것인가**는 말하지 않는다. Tool Service가 임의의 주소를 MCP에 주입할 수 없다. + +마지막 두 원칙은 층이 다르다. **조회**는 bundle마다 독립이고 실패해도 직전 성공본이 남으므로 +평소에는 한 bundle의 장애가 다른 bundle을 건드리지 않는다. 그러나 **카탈로그 교체**는 전부 아니면 +전무다. 한 번도 성공한 적 없는 bundle이 남아 있으면 그 상태로 목록을 확정하지 않는다. +일부만 담긴 목록은 "필요한 Tool이 조용히 사라진 상태"를 만들기 때문이다(§7, §11 W11). + +> **운영 배포에서 bundle은 항상 하나다.** MCP 배포 하나가 Tool Service 하나만 보기로 했기 때문이다 +> ([ADR-0007](../../decisions/ADR-0007-one-mcp-per-tool-service.md)). 따라서 여러 bundle을 전제로 한 +> 규칙(§7의 4·6번, `maxToolsTotal`)은 운영에서 발동하지 않는다. 계약과 구현은 N개를 계속 지원하지만 +> 배포 정의가 1개로 잠그며, 그 사실은 `HelmDeploymentContractTest`가 검사한다. + +## 2. 왜 조회 방식인가, 왜 기동 시 1회가 아닌가 + +### push를 채택하지 않은 이유 + +Tool Service가 MCP로 등록을 보내는 방식은 **MCP Server가 재기동되면 카탈로그를 복구할 방법이 없다.** +Tool Service는 이미 등록을 마쳤으므로 다시 보내지 않고, MCP는 빈 상태로 서비스한다. +재기동 빈도는 오히려 MCP 쪽이 높다(배포·스케일·노드 이동). + +조회 방식은 MCP가 스스로 물어보므로 이 문제가 성립하지 않는다. +또한 MCP에 쓰기 endpoint를 열지 않아도 된다. + +### 기동 시 1회로 끝내지 않는 이유 + +조회 방식이라도 기동 시 1회만 하면 아래를 따라가지 못한다. + +| 상황 | 기동 시 1회만 | 주기적 조회 | +|---|---|---| +| MCP 재기동 | ✅ 다시 조회하므로 복구 | ✅ | +| Tool이 Tool 목록·schema 변경 | ❌ MCP 재기동 전까지 모름 | ✅ 다음 주기 반영 | +| Tool Service 장애 | ❌ 계속 노출 | ✅ 직전 성공본 유지, 정상 응답에서 삭제 확인 시 제거 | +| MCP 기동 시점에 Tool이 배포 중이라 응답 실패 | ❌ **영구 누락** | ✅ 다음 주기 복구 | + +마지막 항목이 가장 위험하다. 조회는 반드시 주기적이어야 한다. + +## 3. MCP 설정 (YAML) + +조회 대상과 라우팅 주소를 선언한다. 예시는 +[mcp-bundle-config.yaml](examples/bundle-v0.2/mcp-bundle-config.yaml)에 있다. + +```yaml +mcp: + identity: mcp-insurance-core + registry: + refreshIntervalSeconds: 30 + discovery: + enabled: true + connectTimeoutMillis: 1000 + readTimeoutMillis: 3000 + maxToolsPerBundle: 100 + maxToolsTotal: 200 + maxManifestBytes: 1048576 + maxToolTimeoutMillis: 30000 + bundles: + # 운영 배포에서 이 목록은 항상 한 항목이다(ADR-0007). 스키마는 N개를 허용한다. + - id: insurance-processing + manifestUrl: http://tool-processing.ax-hub.svc.cluster.local:8080/tool-manifest + baseEndpoint: http://tool-processing.ax-hub.svc.cluster.local:8080/mcp + namePrefix: "processing." + # local 검증에서만 사용. 최초 원격 조회 실패 때만 읽으며 운영 Helm에는 넣지 않는다. + fallbackManifestFile: file:./config/local-process-tools-manifest-sample-v1.json + enabled: true +``` + +| 항목 | 설명 | +|---|---| +| `discovery.enabled` | 운영에서는 `true`이며 bundle 매니페스트를 원천으로 사용한다. `false`는 legacy local JSON fixture에만 사용한다 | +| `manifestUrl` | 매니페스트 조회 주소 | +| `baseEndpoint` | **Tool 실행 주소.** Pod IP가 아니라 Service URL을 사용한다 | +| `namePrefix` | 이 bundle이 사용할 수 있는 Tool 이름 접두사 | +| `fallbackManifestFile` | 선택. 최초 원격 조회 실패 때만 읽을 local manifest 파일. 운영 Helm에는 설정하지 않는다 | +| `enabled` | `false`면 조회하지 않는다. Actuator 상태에는 `status: "disabled"`로 나타난다 | + +`manifestUrl`과 `baseEndpoint`를 나눈 이유는 매니페스트 제공 경로와 실행 경로가 다를 수 있기 때문이다. +같아도 무방하다. + +원격 매니페스트와 legacy local JSON fixture는 **배타적**이다. `ToolRegistryClient` 구현은 +`discovery.enabled`로 선택된다. 다만 local profile에서 원격 조회를 켠 경우에는 bundle별 +`fallbackManifestFile`을 둘 수 있다. 이는 **최초 원격 조회가 실패했을 때만** 읽는 같은 매니페스트 형식의 +cold-start fallback이며, 원격 정상 목록이나 직전 성공본을 덮어쓰지 않는다. + +| profile | `discovery.enabled` | 등록되는 원천 | 결과 | +|---|:---:|---|---| +| `local` | `false` | `LocalFileToolRegistryClient` | legacy JSON fixture만 사용 | +| `local` | `true` | `ToolBundleRegistryClient` | 원격 우선, 설정 시 local manifest fallback | +| `local` 아님(`ocp` 등) | `true` | `ToolBundleRegistryClient` | 정상. 운영 | +| `local` 아님 | `false` | 없음 | **기동 실패** | + +원천이 하나도 없으면 `ToolRegistryService`가 주입받을 bean이 없어 기동 단계에서 멈춘다. +빈 Tool 목록으로 조용히 뜨는 것보다 낫지만, 오류 메시지가 Spring의 bean 해석 실패이므로 +원인을 바로 알기 어렵다. 두 profile YAML이 이미 올바른 값을 고정하고 있으므로 +(`application-local.yml`과 `application-ocp.yml`은 `true`; legacy local fixture만 쓸 때에만 `false`) +새 profile을 추가할 때만 주의하면 된다. + +### 조회 주기와 jitter + +주기는 기존 `mcp.registry.refreshIntervalSeconds`를 사용한다. replica가 동시에 기동할 때 +조회 쏠림을 줄이기 위해 첫 **scheduled refresh**에만 `refreshJitterSeconds` 범위의 bounded jitter를 더한다. +ApplicationReady 직후 warm start와 원천 preload는 빈 목록 구간을 줄이기 위해 jitter 없이 즉시 실행한다. + +### 기동 시 검증 + +아래를 위반하면 **기동에 실패한다.** 잘못된 설정이 운영 중 엉뚱한 라우팅으로 나타나는 것보다 낫다. + +| 규칙 | 이유 | +|---|---| +| `discovery.enabled=true`이면 `bundles`가 비어 있을 수 없다 | 이 상태로 뜨면 `tools/list`가 영구히 빈다 | +| `id`는 중복될 수 없다 | 상태 추적 단위가 겹친다 | +| `namePrefix`는 중복될 수 없고 다른 prefix의 접두사도 될 수 없다 | `a.`와 `a.b.`가 함께 있으면 `a.b.search`의 소속이 확정되지 않는다 | + +## 4. Tool Service가 제공할 endpoint + +```text +GET {manifestUrl} +Accept: application/json +If-None-Match: "<직전 revision>" # 선택 +``` + +매니페스트 조회는 사용자 요청이 아니라 **배경 갱신**이다. 특정 호출자의 요청 context가 없으므로 +correlation·사원 식별자 header를 붙이지 않는다. + +응답: + +```text +200 OK +Content-Type: application/json +ETag: "sha256:9f2c..." # 선택. revision과 같은 값 +``` + +응답 예시는 [manifest-response.json](examples/bundle-v0.2/manifest-response.json)을 따른다. + +`If-None-Match`가 현재 `revision`과 같으면 `304 Not Modified`를 본문 없이 반환해도 된다. +MCP는 이 경우 직전 매니페스트를 그대로 유지한다. **선택 기능이며 구현하지 않아도 계약을 만족한다.** + +이 endpoint는 인증을 요구하지 않아도 되지만, **NetworkPolicy로 MCP Server에서만 접근 가능하도록 +제한한다.** Tool 이름·설명·schema는 내부 시스템 구조를 드러내므로 클러스터 전체에 공개하지 않는다. + +## 5. 매니페스트 스키마 + +### 최상위 필드 + +| 필드 | 필수 | 설명 | +|---|:---:|---| +| `bundleId` | 예 | MCP 설정의 `id`와 일치해야 한다. 다르면 그 응답을 버린다 | +| `revision` | 아니오 | 매니페스트 버전. 변경 감지·로그·ETag에만 쓰인다 | +| `tools` | 예 | 이 bundle이 노출하는 Tool 전체. 빈 배열은 "노출할 Tool 없음"이다 | + +`baseEndpoint`는 **매니페스트에 넣지 않는다.** 넣어도 MCP는 무시한다(§1 세 번째 원칙). + +### `tools[]` 필드 + +| 필드 | 필수 | 설명 | +|---|:---:|---| +| `name` | 예 | MCP 표준에 맞춘 `[A-Za-z0-9_./-]{1,64}`이며 bundle의 `namePrefix`로 시작해야 한다 | +| `title` | 아니오 | 표시용 이름 | +| `description` | 예 | 에이전트가 Tool 선택에 사용한다. 언제 쓰는 도구인지 명확히 쓴다 | +| `inputSchema` | 예 | JSON Schema 2020-12 | +| `outputSchema` | 아니오 | `structuredContent` 응답 구조. 현재 MCP는 구조화 출력을 만들지 않으므로 운영에서는 사용하지 않는다 | +| `annotations` | 아니오 | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` | +| `_meta.version` | 예 | Tool 버전 | +| `_meta.timeoutMillis` | 아니오 | MCP 설정의 `maxToolTimeoutMillis`로 상한을 건다 | +| `_meta.enabled` | 아니오 | 기본 `true`. `false`면 `tools/list`에 노출하지 않는다 | + +`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`는 MCP가 `tools/list`로 +그대로 공개한다. `_meta`는 공개하지 않는다. + +현재 MCP의 `tools/call`은 `content[0].text`만 반환하고 `structuredContent` 생성·응답 schema 검증은 하지 않는다. +MCP 2025-06-18에서 `outputSchema`를 선언한 서버는 이에 맞는 구조화 결과를 제공해야 하므로, Tool Service는 +구조화 출력 지원이 별도 계약으로 반영되기 전까지 운영 매니페스트에서 `outputSchema`를 생략한다. + +## 6. MCP의 조회 동작 + +| 항목 | 권장값 | 근거 | +|---|---|---| +| 주기 | `30초` | 변경 반영 지연의 상한 | +| 첫 scheduled refresh jitter | `0~5초` | 반복 조회 주기가 replica마다 같은 시점에 고정되는 것을 방지 | +| 연결 timeout | `1초` | | +| 읽기 timeout | `3초` | | +| 기동 시 | **즉시 1회 조회하되 기동을 막지 않는다** | Tool 장애가 MCP 기동 실패로 번지지 않게 | +| readiness | **첫 조회 시도 완료 + usable snapshot이면 ready** | 원천 또는 Redis last-good이 있어 실제 요청을 처리할 수 있을 때만 트래픽을 받는다 | + +usable snapshot은 원천 조회 성공본, 최초 원격 조회 실패 때 채택한 local fallback, 또는 Redis에서 채택한 last-good이다. 정상 매니페스트가 반환한 빈 Tool +목록도 유효한 전체 상태다. 반대로 첫 조회가 끝났더라도 memory와 Redis에 성공본이 하나도 없으면 readiness는 +DOWN을 유지하고 다음 주기 조회를 기다린다. + +### 동시 조회 + +bundle N개를 **동시에** 조회한다. 순차 조회하면 소요 시간이 합산되어 기동과 갱신이 지연된다. + +개별 조회 실패는 **예외가 아니라 결과값**으로 다룬다. 하나의 실패가 전체 조회를 중단시키면 +나머지 성공분까지 버려진다. + +### 실패 판정과 유예 + +| 조회 결과 | 처리 | +|---|---| +| 성공 + 검증 통과 | 새 매니페스트 채택 | +| 성공 + 검증 실패 | 직전 성공본 유지. 실패 횟수 증가 | +| timeout / 연결 실패 / 5xx | 직전 성공본 유지. 실패 횟수 증가 | +| `304 Not Modified` | 직전 성공본 유지. 실패 횟수 **초기화** | + +`304`는 **아직 구현하지 않았다.** §4에서 선택 기능으로 둔 항목이므로 `If-None-Match`를 보내지 않고, +Tool Service가 `304`를 반환할 일도 없다. 매번 전체 매니페스트를 받아 채택한다. + +구현상 실패는 **예외가 아니라 결과값**이다. 조회 작업이 예외를 그대로 올리면 `Future` 하나가 깨지면서 +나머지 bundle의 성공분까지 함께 버려지기 때문이다. bundle별 작업이 자기 예외를 잡아 실패 결과로 바꾸고, +그 바깥에 예상 밖의 오류까지 흡수하는 2차 방어선을 둔다. + +## 7. 병합 규칙 + +1. **이름 검증** — `namePrefix`로 시작하지 않는 Tool은 그 bundle 전체를 거부한다 +2. **`enabled: false` 제외** — 등록은 하되 `tools/list`에 노출하지 않는다 +3. **상한 검사** — `maxToolsPerBundle` 초과 시 그 bundle 거부, `maxToolsTotal` 초과 시 전체 aggregate 거부 +4. **정렬** — `(bundleId, name)` 오름차순으로 정렬한다 +5. **`timeoutMillis` 상한** — `maxToolTimeoutMillis`를 넘는 값은 상한으로 절삭한다 +6. **bundle 간 이름 충돌** — 어느 Tool도 임의 선택하지 않고 전체 aggregate를 거부한다 + +1번은 Tool 하나가 규칙을 어겨도 **bundle 전체를 거부**한다는 뜻이다. 필수 필드 누락, `bundleId` 불일치, +매니페스트 내부 이름 중복도 같다. 일부만 반영된 카탈로그는 "필요한 Tool이 조용히 사라진 상태"를 만들어, +직전 성공본을 유지하는 것보다 나쁘다. + +6번을 정렬 **뒤에** 두는 이유는 1번과 같다. 정렬 전에 처리하면 어느 쪽이 살아남는지가 +동시 조회의 응답 순서에 좌우되어 replica마다 달라진다. + +정렬이 없으면 동시 조회 응답 순서에 따라 `tools/list` 순서가 매번 달라진다. +Agent Builder 쪽 프롬프트가 매 호출 달라져 캐시 적중률이 떨어지므로 반드시 정렬한다. + +`maxToolsTotal`은 [ADR-0002](../../decisions/ADR-0002-tool-exposure-and-single-call.md)의 +Tool 노출 상한과 함께 검토한다. 운영 배포는 bundle이 하나이므로(ADR-0007) 이 상한은 +`maxToolsPerBundle`과 같은 층에서 동작하며, 50개 노출 상한은 한 Agent가 **여러 MCP에서 가져온 +Tool의 합계**에 적용된다. MCP를 나눈다고 상한이 늘지 않는다. + +## 8. Tool을 찾지 못했을 때의 재확인 + +`tools/call` 요청의 Tool이 현재 스냅샷에 없으면, **해당 bundle을 즉시 1회 재조회한 뒤** +그래도 없으면 `-32001 Tool not found`로 응답한다. + +MCP replica마다 조회 시점이 달라 스냅샷이 일시적으로 어긋날 수 있기 때문이다(§11 W2). + +구현은 해당 bundle 하나가 아니라 **전체를 한 번 재조회**한다. 재조회 대상은 병렬이고 timeout이 짧아 +비용 차이가 작은 반면, "어느 bundle에 속한 Tool인가"를 이름만으로 되짚는 경로를 따로 두지 않아도 된다. +계약이 요구하는 것(한 번 더 확인한 뒤 판정)은 그대로 만족한다. + +## 9. 운영 상태 조회 + +```text +GET /actuator/toolBundles +``` + +management port(운영 기본 9090)에서 MCP가 알고 있는 bundle의 조회 상태를 반환한다. 외부 ingress에는 +노출하지 않는다. 응답 예시는 +[bundle-status-response.json](examples/bundle-v0.2/bundle-status-response.json)에 있다. + +설정에 선언되어 있으나 한 번도 조회에 성공하지 못한 bundle도 반환한다. +**설정에 기대값이 있으므로 누락 감지가 가능하다.** + +| `status` | 의미 | +|---|---| +| `healthy` | 마지막 조회 성공 | +| `fallback` | 최초 원격 조회에 실패해 local manifest sample을 사용 중 | +| `degraded` | 최근 조회는 실패했지만 직전 성공본을 계속 노출 중 | +| `unreachable` | 켜져 있으나 한 번도 성공한 적 없음 | +| `disabled` | 설정에서 `enabled: false` | + +응답에 **`manifestUrl`과 `namePrefix`는 넣지 않는다.** 진단에 꼭 필요하지 않은데 내부 주소 체계를 더 드러낸다. +`lastFailureReason`도 메시지가 아니라 **예외 타입 이름만** 담는다. 메시지에는 URL이나 응답 조각이 섞일 수 있다. + +읽기 전용이며 상태를 바꾸지 않는다. 그러나 내부 구조를 노출하므로 외부에 공개하지 않는다. + +이 endpoint는 Spring Boot Actuator가 제공하므로 `/mcp`의 JSON-RPC 예외 처리 경계를 통과하지 않는다. + +## 10. 실행 경로 (MCP → Tool, 현재 구현) + +이미 구현되어 있는 계약이다. Tool Service는 아래를 받을 수 있어야 한다. + +```text +POST {baseEndpoint}/{toolName} +Content-Type: application/json +guid, x-request-id, mcp-session-id, employee-no, virtual-employee-no +Authorization: <설정에 따라 전달> + + +``` + +- 호출자 header 다섯 개는 **이름과 값을 바꾸지 않고 그대로 bypass**한다. 값이 없는 header는 보내지 않는다. +- `employee-no`·`virtual-employee-no`는 호출자가 암호화한 값이다. **복호화는 Tool Service 몫이며 + 사내 KMS에서 발급받은 키를 사용한다.** MCP는 키를 갖지 않으므로 값을 읽지도, 로그에 남기지도 못한다. + MCP가 인증을 하지 않으므로([ADR-0006](../../decisions/ADR-0006-no-authentication-in-mcp.md)) + 이 값의 신뢰 여부는 Tool Service가 판단한다. 두 header가 모두 없을 수 있다는 점도 함께 고려한다. +- 발송·등록·변경 Tool의 중복 실행 방지는 Tool Service 책임이다. retry가 같은 `guid`를 재사용할지와 + 이를 멱등성 키로 사용할지는 아직 합의되지 않았으므로 현재 wire 계약으로 가정하지 않는다. + 합의 대상은 [extension-points.md](../../extension-points.md)에 한 번만 관리한다. +- 요청 body는 에이전트가 보낸 `arguments` 객체 **그대로**다. MCP는 이름·값을 바꾸지 않는다. +- 응답이 JSON object/array면 MCP가 compact JSON 문자열로 `result.content[0].text`에 담는다. +- Tool이 반환한 HTTP 4xx/5xx와 timeout은 `result.isError: true`로 변환한다. +- 호출 소요 시간은 `result.content[0]._meta.searchTime`(ms)로 반환한다. + +향후 Tool Service를 MCP 서버로 만들면 매니페스트 조회를 표준 `tools/list`로, 실행을 표준 +`tools/call`로 대체할 수 있다. 이 경우 §4·§5는 MCP 표준으로 흡수된다. 전환 여부는 합의 항목이다. + +## 11. 트레이드오프와 확장 경계 + +현재 선택은 **Tool Service 원천 + 주기 pull + in-memory last-good + 선택 Redis 공유 cache**다. +Redis의 key 형식, TTL, 공유 범위와 운영 정책은 이 Tool Service wire 계약의 범위가 아니며 +[extension-points.md](../../extension-points.md#운영-적용-전-필수-보완)에서 합의한다. + +| 트레이드오프 | 현재 선택 | +|---|---| +| replica snapshot 차이 | 일시 허용. 성공본만 교체하고 Tool miss 시 원천을 한 번 재확인 | +| 변경 반영 지연 | 기본 한 주기 허용. 즉시 알림·ETag는 아직 도입하지 않음 | +| 조회 부하 | replica별 조회 허용. 규모가 커질 때만 leader election 검토 | +| 기동 중 원천 장애 | Redis warm start 후 즉시 preload. local profile에 fallback 파일이 있으면 최초 실패 때만 채택하고, 없으면 다음 조회까지 Registry unavailable | +| stale Tool | 조회 실패만으로 삭제하지 않음. 성공한 매니페스트에서 빠진 경우에만 삭제 | +| Redis 장애 | cache miss로 격리. 요청 경로는 in-memory만 조회 | + +NetworkPolicy·egress, 관측 지표, retry/idempotency, outputSchema 등 아직 합의하거나 보완할 내용은 +[extension-points.md](../../extension-points.md)에서만 관리한다. 구현 목록은 코드와 테스트가 정본이며 이 계약에 다시 나열하지 않는다. + +## 12. 의도적으로 넣지 않은 기능 + +- `If-None-Match` / `304`: 매니페스트가 작아 현재 효용이 없음 +- 즉시 refresh 알림 endpoint: 주기 반영으로 부족하다는 운영 근거가 생길 때 검토 +- leader election: replica 조회 부하가 실제 병목이 될 때 검토 +- 상태 응답의 `manifestUrl`·`namePrefix`: 불필요한 내부 주소 노출 방지 diff --git a/docs/decisions/ADR-0001-stateless-execution-boundary.md b/docs/decisions/ADR-0001-stateless-execution-boundary.md new file mode 100644 index 0000000..7fea2c3 --- /dev/null +++ b/docs/decisions/ADR-0001-stateless-execution-boundary.md @@ -0,0 +1,23 @@ +# ADR-0001 Stateless 실행 책임 경계 + +- 상태: Accepted +- 결정일: 2026-07-10 +- 관련 논의: [DISC-20260710-001](../discussions/DISC-20260710-001-agentbuilder-mcp-interface.md) + +## 배경 + +Agent Builder는 사용자 의도와 업무 맥락을 바탕으로 실행할 Tool을 결정하고, MCP Server는 요청된 Tool을 안전하게 검증·실행하는 계층이다. + +## 결정 + +- MCP Server는 서버 측 대화 세션을 보관하지 않는 stateless 실행 계층으로 유지한다. +- Tool 선택, 의도 분류, 대체 Tool 탐색은 Agent Builder 책임이다. +- MCP Server는 요청에 명시된 Tool만 Registry metadata에 따라 검증하고 실행한다. +- `mcp-session-id`가 사용되더라도 대화 상태가 아닌 correlation 값으로 취급한다. +- Business Rule은 Tool Service 책임으로 유지한다. + +## 영향 + +- MCP Server에 LLM inference, intent classification 또는 autonomous tool selection을 추가하지 않는다. +- 인증·권한 책임은 이 결정 이후 [ADR-0006](ADR-0006-no-authentication-in-mcp.md)에서 확정했다. MCP는 인증·인가하지 않는다. +- 현재 구현은 이 결정과 대체로 일치하므로 즉시 코드 변경하지 않는다. diff --git a/docs/decisions/ADR-0002-tool-exposure-and-single-call.md b/docs/decisions/ADR-0002-tool-exposure-and-single-call.md new file mode 100644 index 0000000..ff170d7 --- /dev/null +++ b/docs/decisions/ADR-0002-tool-exposure-and-single-call.md @@ -0,0 +1,32 @@ +# ADR-0002 Agent Builder Tool 노출 제한과 단일 Tool 호출 + +- 상태: Accepted +- 결정일: 2026-07-10 +- 관련 논의: [DISC-20260710-001](../discussions/DISC-20260710-001-agentbuilder-mcp-interface.md) + +## 배경 + +많은 Tool을 한 번에 LLM context에 노출하면 Tool 식별과 선택 정확도가 저하될 수 있다. 또한 한 요청에서 여러 Tool을 실행하면 timeout, 부분 성공, 순서 의존성과 오류 계약이 복잡해진다. + +## 결정 + +- Agent Builder가 사용자·Agent·업무 맥락에 맞는 Tool을 선택한다. +- Agent Builder가 한 시점에 LLM에 노출하는 활성 Tool은 최대 50개다. +- 목표 Agent Builder-MCP 호출 모델은 요청 하나당 Tool 하나다. +- MCP Server는 50개 선별 로직이나 LLM 기반 우선순위 판단을 구현하지 않는다. + +## 영향 + +- 50개 선별은 Agent Builder 변경 사항이며 MCP 코드 변경 대상이 아니다. +- 요청 하나당 Tool 하나라는 결정은 그대로 유효하다. + +### 결정 당시의 envelope 기록 (현재 구현 아님) + +결정 시점에는 최종 field 이름이 미확정이어서 `params.toolCalls[]` 배열 envelope를 유지하고, +원소를 정확히 한 개만 허용해 단일 호출을 강제했다. 빈 배열과 2개 이상은 `-32602`로 거절했다. + +이 envelope는 [ADR-0005](ADR-0005-standard-tool-name.md)와 +[계약 v0.3](../contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md)에서 +표준 MCP `params.name` + `params.arguments`로 대체되었다. +**현재 코드에 `toolCalls`는 존재하지 않는다.** 배열이 사라졌으므로 "정확히 한 개" 검증도 +필요 없어졌고, 단일 호출 결정은 envelope 구조 자체로 만족된다. diff --git a/docs/decisions/ADR-0003-builder-tool-uid.md b/docs/decisions/ADR-0003-builder-tool-uid.md new file mode 100644 index 0000000..f58da92 --- /dev/null +++ b/docs/decisions/ADR-0003-builder-tool-uid.md @@ -0,0 +1,29 @@ +# ADR-0003 Builder Tool UID를 시스템 간 식별 키로 사용 + +- 상태: Superseded +- 결정일: 2026-07-10 +- 관련 논의: [DISC-20260710-001](../discussions/DISC-20260710-001-agentbuilder-mcp-interface.md) +- 대체 결정: [ADR-0005](ADR-0005-standard-tool-name.md) + +> 이 문서는 당시 검토 이력을 보존한다. 현재 구현과 신규 연동에는 ADR-0005를 적용한다. + +## 배경 + +Tool name은 중복 또는 변경 가능성이 있으므로 Agent Builder Tool Registry가 부여한 고유 식별자를 시스템 간 mapping key로 사용할 필요가 있다. + +## 결정 + +- Agent Builder Tool Registry가 Custom Tool에 부여한 UID를 Agent Builder-MCP 사이의 Tool 식별 키로 사용한다. +- MCP Server는 UID를 Registry metadata의 Tool name, version, endpoint와 매핑하여 실행한다. +- Tool name과 version은 설명 및 검증 metadata로 유지할 수 있지만 시스템 간 기본 mapping key 역할은 UID가 담당한다. + +## 구현 보류 조건 + +다음 항목은 아직 결정되지 않았으므로 코드 변경 조건이 충족되지 않았다. + +- UID 생성 시점과 Agent Builder/MCP 전달 시점 +- version 변경, 비활성화, 삭제, 재등록 시 UID 규칙 +- 환경별 UID 승격 또는 분리 규칙 +- 목표 요청 payload의 UID field 이름 + +이 보류안은 ADR-0005로 대체되었으며 현재 모델에 UID field를 두지 않는다. diff --git a/docs/decisions/ADR-0004-execution-guardrails.md b/docs/decisions/ADR-0004-execution-guardrails.md new file mode 100644 index 0000000..fb115b8 --- /dev/null +++ b/docs/decisions/ADR-0004-execution-guardrails.md @@ -0,0 +1,30 @@ +# ADR-0004 실행 가드레일 + +- 상태: Accepted +- 결정일: 2026-07-10 +- 관련 논의: [DISC-20260710-001](../discussions/DISC-20260710-001-agentbuilder-mcp-interface.md) + +## 결정 + +- Agent Builder-MCP 상호작용에는 300초 hard limit을 둔다. +- MCP Server는 3만 자 기준으로 Tool Service의 원문 응답을 임의 절단하지 않는다. +- write/update 성격의 Tool은 idempotency가 보장되지 않으면 자동 retry하지 않는다. + +## 해석 경계 + +- 300초는 전체 상한 원칙이며 모든 계층의 socket read timeout을 무조건 300초로 설정한다는 의미가 아니다. +- 원문 미절단은 response body를 무제한 허용한다는 의미가 아니다. +- read Tool이 항상 retry 가능하다는 결정은 아니다. + +## 구현 보류 조건 + +다음 세부 계약이 확정되기 전에는 timeout 또는 retry 코드를 변경하지 않는다. + +- ~~Agent Builder, ingress, MCP, Tool Service별 timeout budget~~ → + **확정 (2026-08-01).** Agent Builder 300초(호출 시점 기준) > MCP 270초 > Tool 30초. + 배분과 근거는 [architecture.md의 요청 시간 예산](../architecture.md#요청-시간-예산)이 정본이다. +- client disconnect와 downstream cancellation 전파 +- response body 최대 크기와 pagination/continuation 정책 +- Tool별 idempotency/retryable metadata와 오류 코드 + +현재 구현에는 3만 자 절단 및 자동 retry가 없으므로 두 원칙에 대한 즉시 코드 변경은 없다. diff --git a/docs/decisions/ADR-0005-standard-tool-name.md b/docs/decisions/ADR-0005-standard-tool-name.md new file mode 100644 index 0000000..97700d7 --- /dev/null +++ b/docs/decisions/ADR-0005-standard-tool-name.md @@ -0,0 +1,25 @@ +# ADR-0005 표준 MCP Tool name을 실행 식별자로 사용 + +- 상태: Accepted +- 결정일: 2026-07-30 +- 대체 대상: [ADR-0003](ADR-0003-builder-tool-uid.md) + +## 배경 + +Tool의 원천 정보는 각 Tool Service가 소유하고 MCP Server가 매니페스트를 pull한다. +Agent Builder의 UID는 Agent Builder 내부 관리 개념이므로 MCP와 Tool Service 사이의 계약으로 +전파하면 표준 `tools/list`와 `tools/call` 외에 별도 식별자 동기화가 필요해진다. + +## 결정 + +- `tools/list`가 노출하고 `tools/call.params.name`이 전달하는 표준 MCP Tool `name`을 실행 식별자로 사용한다. +- Tool Service가 전체 MCP 범위에서 고유한 namespaced name을 직접 선언한다. +- 허용 형식은 MCP 표준에 맞춘 1~64자의 영문·숫자와 `_`, `-`, `.`, `/`다. +- MCP Server는 이름을 재작성하지 않고 형식, bundle의 `namePrefix`, 전체 중복을 검증한다. +- Agent Builder 내부 UID는 Agent Builder가 자체 관리하며 MCP metadata와 실행 요청에 요구하지 않는다. + +## 결과 + +- 표준 MCP 계약만으로 목록과 실행 대상을 연결한다. +- Tool 이름 변경은 식별자 변경이므로 Tool Service와 Agent Builder의 rolling 호환 기간이 필요하다. +- 이름 충돌이나 전체 Tool 수 상한 초과 시 일부 목록을 노출하지 않고 기존 정상 snapshot을 유지한다. diff --git a/docs/decisions/ADR-0006-no-authentication-in-mcp.md b/docs/decisions/ADR-0006-no-authentication-in-mcp.md new file mode 100644 index 0000000..b7c5007 --- /dev/null +++ b/docs/decisions/ADR-0006-no-authentication-in-mcp.md @@ -0,0 +1,77 @@ +# ADR-0006 MCP Server는 인증·인가를 하지 않는다 + +- 상태: Accepted +- 결정일: 2026-08-01 +- 관련 결정: [ADR-0001](ADR-0001-stateless-execution-boundary.md) + +## 배경 + +Tool 실행 권한은 Agent Builder가 Agent를 구성할 때 이미 확인하고 넘어온다. +MCP Server는 Agent Builder가 지정한 Tool을 검증·실행하는 계층이므로 권한을 다시 판단할 근거가 없다. + +문제는 MCP가 **판단하지 않는 계층인 동시에 집행 지점**이라는 데 있다. +MCP는 이 배포에 속한 모든 Tool Service에 도달할 수 있는 유일한 경로다. +따라서 "MCP는 아무것도 하지 않는다"는 결정은, 그러면 **누가 하는가**를 함께 적지 않으면 +세 계층이 서로 상대가 확인했다고 가정하는 공백을 만든다. + +이 문서는 그 공백을 막기 위해 책임 소재를 명시한다. + +## 결정 + +MCP Server는 인증(authentication)과 인가(authorization)를 수행하지 않는다. + +- 요청자의 신원을 검증하지 않는다. +- Tool 실행 권한을 판단하지 않는다. +- `employee-no`·`virtual-employee-no`를 복호화·검증·저장하지 않는다. +- Authorization 헤더를 해석하지 않는다. `mcp.tool-client.forward-authorization`이 켜져 있으면 + 값을 그대로 전달만 한다. + +이에 따라 무검증 JWT decode 구현(`JwtClaimExtractor`, `UnverifiedJwtClaimExtractor`)을 삭제한다. +검증하지 않는 인증 코드는 없는 것보다 나쁘다. 이후 누군가 그 claim을 판단 근거로 쓸 여지를 남기고, +코드베이스에 인증이 처리되고 있다는 잘못된 인상을 준다. + +## 책임 소재 + +| 책임 | 주체 | 근거 | +|---|---|---| +| 호출자가 Agent Builder인지 보장 | **플랫폼(NetworkPolicy)** | Helm Chart의 `templates/networkpolicy.yaml`. 환경별 허용 namespace는 `values-{env}.yaml`의 `global.agentBuilderNamespace` | +| 사용자 인증, Tool 실행 권한 판단 | **Agent Builder** | Agent 구성 시점에 확인 | +| 사원 식별자 복호화와 업무 권한 집행 | **Tool Service** | KMS에서 발급받은 키 사용 | +| 요청 형식·schema 검증, 단일 Tool 실행 | **MCP Server** | ADR-0001 | + +## 이 결정이 성립하기 위한 전제 + +**전제 1 — 네트워크가 호출자를 고정한다.** +MCP는 요청자를 확인하지 않으므로, `/mcp`에 도달할 수 있다는 것 자체가 곧 인가다. +NetworkPolicy로 Agent Builder namespace만 8080에 접근하도록 제한한다. +**이 정책 없이 배포하면 클러스터 안의 어떤 Pod이든 Tool을 실행할 수 있다.** +정책은 선택적 강화가 아니라 이 ADR의 성립 조건이다. +그래서 Helm Chart에 비활성화 스위치를 두지 않았고, `HelmDeploymentContractTest`가 +조건부 렌더링이 들어오는 것까지 막는다. values 한 줄로 인가가 사라지지 않게 하기 위해서다. + +**전제 2 — 사원 식별자의 신뢰는 Tool Service가 확보한다.** +`employee-no`·`virtual-employee-no`는 암호화되어 전달되고, 복호화 키는 사내 KMS에서 발급받는다. +MCP는 키를 갖지 않으므로 값을 읽을 수 없고, 따라서 위조 여부도 판별할 수 없다. + +Tool 파트가 확인할 항목: + +- 복호화는 KMS 키로만 가능하므로 **기밀성**은 확보된다. + **위조 방지**는 암호화 키의 배포 범위에 달려 있다. 암호화 키를 널리 배포하면 + 임의의 사원번호를 스스로 암호화해 넣을 수 있으므로, 키 배포 범위를 신뢰 경계와 맞춘다. +- 동일한 암호문의 재사용(replay)을 허용할지, 만료·nonce를 둘지 결정한다. +- 두 헤더가 모두 없는 요청의 처리 방침을 정한다. 현재 계약에서 두 값은 선택값이다. + +**전제 3 — 감사 추적은 세 계층의 로그를 합쳐야 완성된다.** +MCP 로그에는 `guid`와 `x-request-id`만 남는다. +사원 식별자는 개인 식별자이므로 암호문이라도 기록하지 않는다. +따라서 "누가 조회했는가"는 MCP 로그만으로 답할 수 없다. +`guid`를 Agent Builder·MCP·Tool Service가 공통 상관 키로 사용해 세 로그를 연결한다. +규제 감사 요건이 확정되면 별도 durable sink를 설계한다. + +## 영향 + +- MCP에 인증 코드를 추가하지 않는다. 필요가 생기면 이 ADR을 대체하는 새 ADR을 먼저 쓴다. +- NetworkPolicy는 배포 필수 구성요소다. 누락은 설정 실수가 아니라 보안 결함으로 다룬다. +- 인증 모델이 token 기반으로 바뀌면 이 결정과 전제 1을 함께 재검토한다. +- `forward-authorization` 설정은 유지한다. 검증이 아니라 통과 전달이며, + Tool Service가 자체 인증을 도입할 때 필요한 연결점이다. diff --git a/docs/decisions/ADR-0007-one-mcp-per-tool-service.md b/docs/decisions/ADR-0007-one-mcp-per-tool-service.md new file mode 100644 index 0000000..ac54cc9 --- /dev/null +++ b/docs/decisions/ADR-0007-one-mcp-per-tool-service.md @@ -0,0 +1,96 @@ +# ADR-0007 MCP 배포 하나는 Tool Service 하나만 본다 + +- 상태: Accepted +- 결정일: 2026-08-02 +- 관련: [ADR-0001](ADR-0001-stateless-execution-boundary.md), [ADR-0002](ADR-0002-tool-exposure-and-single-call.md), [ADR-0009](ADR-0009-container-handles-public-mcp-path.md), [계약 v0.2](../contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md) + +외부에서 여러 MCP를 하나의 host 아래 path로 묶는 방식은 [ADR-0009](ADR-0009-container-handles-public-mcp-path.md)이 +소유한다. OpenShift Route가 원래 path를 유지한 채 각각의 독립 배포로 연결하므로 이 ADR의 1:1 결정은 그대로 유지된다. + +## 배경 + +MCP 설정의 `mcp.bundles`는 여러 Tool Service를 하나의 카탈로그로 병합할 수 있다. 이 능력을 실제로 쓸지, +즉 MCP와 Tool Service를 M:N으로 묶을지는 결정되지 않은 상태였다. + +고객 요구는 **Tool의 군집화**다. MCP 자체를 군집화해 달라는 요구가 아니다. 요구의 목적은 가용성이며, +중요한 Tool은 다운이 없어야 한다는 것이다. 분할 기준은 먼저 업무로 나누고, 그 안에서 중단 시 업무 +영향도와 가용성 위험도로 다시 나누는 형태다. 예: 처리계-중요, 처리계-비중요, 정보계-중요, +정보계-비중요. 여기서 위험도는 보안·권한 정책이 아니라 **서비스 중단 위험**을 뜻한다. 업무 정책은 +Tool Service가 관리하며 이 배포 등급의 범위가 아니다. + +Tool 목록이 확정되지 않아 Tool Service가 몇 개가 될지 모르며, 10~20개 이상이 될 수 있다. + +## 결정 + +1. **MCP 배포 하나는 Tool Service를 정확히 하나 본다.** `mcp.bundles`는 항상 한 항목이다. +2. 배포 단위의 분할 축은 **업무 × 등급(tier)** 이다. 등급은 `critical`과 `standard`로 둔다. +3. 등급은 **배포 속성일 뿐 wire 계약에 나타나지 않는다.** Tool 이름·`namePrefix`·매니페스트에 등급을 넣지 않는다. +4. 다중 bundle 병합 코드는 **삭제하지 않고 유지**하되, 배포 설정에서 bundle 1개로 잠근다. + +## 근거 + +### 등급이 다른 Tool Service를 한 MCP가 보면 격리가 깨진다 + +`ToolBundleRegistryClient.fetchTools()`는 사용 가능한 성공본이 없는 bundle이 하나라도 있으면 +카탈로그 전체 교체를 거부한다(계약 v0.2 §1, §7). 한 MCP가 중요·비중요 Tool Service를 함께 보면 +**비중요 쪽 조회가 확정되지 않는 동안 중요 Tool의 카탈로그 갱신까지 멈춘다.** 직전 성공본으로 +서빙은 계속되지만 변경 반영은 막힌다. + +여기에 두 Tool Service 호출이 같은 프로세스의 HTTP connection pool과 스레드를 공유하므로, +비중요 쪽 지연이 중요 쪽 여유를 잠식한다. + +**병합은 가용성 요구와 정면으로 충돌한다.** 등급을 나눈 목적을 배포 구조가 되돌려 놓는다. + +### 병합해서 얻는 것이 없다 + +MCP에는 업무 로직이 없다. 여러 Tool Service를 하나로 합치는 일이 MCP 안에서 일어나야 할 +기술적 이유가 없다. Agent Builder는 MCP를 개별 등록하면서 하위 Tool 정보를 자기 DB에 저장하고, +사용자 요청을 판단한 뒤 **해당 Tool을 가진 MCP로 호출을 보낸다.** 여러 Tool 묶음을 아우르는 일은 +Tool 선택을 이미 수행하는 Agent Builder 계층에서 끝난다. + +런타임에 공유되는 공통 Tool Service도 없다. Tool 파트의 `tool-common`은 각 Tool Service 프로젝트가 +함께 빌드하는 **빌드 타임 라이브러리**이지 별도로 뜨는 서비스가 아니다. 1:1을 깨야 할 사례가 남지 않는다. + +### 1:1이라야 등급별로 다른 비용을 쓸 수 있다 + +한 MCP가 등급을 섞어 들고 있으면 그 배포 전체에 중요 등급 기준을 적용해야 한다. 나뉘어 있으면 +`critical`에만 replica 여유와 PodDisruptionBudget을 주고 `standard`는 최소로 둘 수 있다. +**분할의 실질 이득은 격리 자체보다 여기에 있다.** + +다만 배포를 나누는 것만으로 가용성이 생기지는 않는다. 같은 노드 배치, 같은 namespace의 쿼터, +공통 Redis·클러스터 장애는 분할로 막히지 않는다. 등급 분리가 의미를 가지려면 replica 하한, +PodDisruptionBudget, anti-affinity와 usable Tool snapshot 기반 readiness가 함께 가야 한다. Chart의 +`tiers` 설정과 `HelmDeploymentContractTest`가 test·prod의 정적 values를 검사하며, 실제 렌더링 결과는 +배포 파이프라인의 `helm lint`와 `helm template`이 확인한다. + +## 전제 + +아래가 깨지면 이 결정을 재검토한다. + +1. Agent Builder는 MCP를 개별 등록하고, 한 Agent가 여러 MCP의 Tool을 사용할 수 있다. +2. Tool 호출은 한 요청에 하나이며([ADR-0002](ADR-0002-tool-exposure-and-single-call.md)) 그 Tool을 가진 MCP로 직접 간다. +3. 런타임에 공유되는 공통 Tool Service가 없다. + +## 영향 + +- **배포 수 = Tool Service 수**다. 10~20개를 전제로 Helm values는 토폴로지를 한 파일에 모으고 + 배포 시 `deploymentKey`로 하나를 고른다. 배포가 늘어도 파일 수는 변하지 않는다. +- 계약 v0.2 §7의 병합 규칙 중 bundle 간 이름 충돌(6번)과 `maxToolsTotal`(3번 후단)은 운영에서 발동하지 않는다. + 규칙 자체는 계약에 남는다. +- **Tool 이름의 전역 유일성은 Tool Service 책임으로 남는다.** 서로 다른 MCP가 같은 `namePrefix`를 + 쓰는 것을 MCP는 막지 못한다. 등급으로 나뉜 두 배포가 같은 업무 prefix(`processing.`)를 공유하는 것은 + 의도된 구성이며, 그 안에서 Tool 이름이 겹치지 않아야 한다. +- Tool을 다른 등급으로 옮기면 그 Tool을 제공하는 **MCP endpoint가 바뀐다.** Agent Builder가 Tool 정보를 + DB에 보관하므로 반영에는 재등록 또는 다음 `tools/list` 주기가 필요하다. 등급은 자주 바꾸지 않는 값으로 다룬다. +- [ADR-0002](ADR-0002-tool-exposure-and-single-call.md)의 Tool 노출 상한 50개는 한 Agent가 여러 MCP에서 + 가져온 Tool의 **합계**에 적용된다. MCP를 나눈다고 상한이 늘지 않는다. + +## 채택하지 않은 대안 + +**M:N — 한 MCP가 여러 Tool Service를 본다.** 배포 수는 줄지만 위의 격리 문제가 그대로 남는다. +가용성이 분할의 목적이므로 목적과 수단이 어긋난다. + +**코드에서 bundle 1개를 강제한다.** `McpProperties`에 검증을 넣으면 다중 bundle 병합 코드가 +도달 불가능해진다. 전제 3이 깨질 때 되돌리는 비용이 커지고, 이미 작성·테스트된 경로를 죽은 코드로 +만든다. 1:1은 애플리케이션 불변식이 아니라 **배포 결정**이므로 배포 정의에서 잠그는 편이 맞다. +이 선택은 검증 위치를 옮긴 것이지 검증을 뺀 것이 아니다. diff --git a/docs/decisions/ADR-0008-shared-host-path-routing.md b/docs/decisions/ADR-0008-shared-host-path-routing.md new file mode 100644 index 0000000..df98720 --- /dev/null +++ b/docs/decisions/ADR-0008-shared-host-path-routing.md @@ -0,0 +1,53 @@ +# ADR-0008 공유 host의 path를 독립 MCP 배포로 연결한다 + +- 상태: Accepted +- 결정일: 2026-08-05 +- 관련: [ADR-0006](ADR-0006-no-authentication-in-mcp.md), [ADR-0007](ADR-0007-one-mcp-per-tool-service.md) + +## 배경 + +Agent Builder에는 하나의 HTTPS host를 제공하고 업무별 MCP를 `/mcp/core`, `/mcp/process`, +`/mcp/information` 같은 path로 구분해야 한다. 이를 하나의 애플리케이션 프로세스에서 처리하면 +Registry snapshot·Redis cache·readiness·connection 자원을 route별로 다시 나눠야 하고, 한 프로세스의 +장애와 배포가 모든 Tool Service에 전파된다. + +## 결정 + +1. 환경마다 공개 MCP host를 하나 둔다. +2. `deployments..publicPath`는 한 MCP Deployment를 가리키며 topology 전체에서 유일하다. +3. OpenShift Route가 공개 path를 해당 MCP Service로 전달하고 내부 고정 endpoint `/mcp`로 rewrite한다. +4. MCP 애플리케이션과 Tool Service의 1:1 매핑, Registry snapshot, Redis key와 readiness는 배포별로 유지한다. +5. Agent Builder는 각 공개 URL을 독립 MCP endpoint로 등록하고 endpoint별로 initialize한다. +6. 요청 body·header·Tool 이름은 어느 MCP Service로 보낼지 결정하지 못한다. 대상은 Helm topology만 정한다. + +```text +https://{host}/mcp/core -> core MCP /mcp -> core Tool Service +https://{host}/mcp/process -> process MCP /mcp -> process Tool Service +https://{host}/mcp/information -> information MCP /mcp -> information Tool Service +``` + +가용성 등급으로 같은 업무를 둘로 나누면 path도 구분한다. 예를 들어 +`/mcp/process-critical`과 `/mcp/process-standard`는 서로 다른 MCP Deployment와 Tool Service를 가리킨다. + +## 근거 + +- 외부 URL은 한 host로 단순화하면서 내부 장애 범위는 업무·가용성 등급별로 유지한다. +- 현재 Java transport·Registry·cache 코드를 route-aware Gateway로 바꾸지 않아도 된다. +- 한 Tool Service의 조회 실패나 부하가 다른 MCP의 readiness와 snapshot 갱신을 막지 않는다. +- 공개 path와 Service 매핑을 한 topology에서 검토하고 계약 테스트로 중복·형식을 막을 수 있다. + +## 영향 + +- 하나의 Helm release는 Deployment·Service·Route를 하나씩 만든다. 여러 release의 Route가 같은 host와 서로 다른 path를 사용한다. +- OpenShift Router가 TLS를 종료하고 path를 rewrite하므로 backend 애플리케이션은 계속 `POST /mcp`만 처리한다. +- 공개 URL 변경 시 Agent Builder 재등록 또는 endpoint 설정 변경이 필요하다. +- Gateway 기능은 OpenShift Route가 소유한다. MCP Java 프로세스는 Tool 선택이나 다른 MCP로의 proxy를 하지 않는다. +- 실제 환경 host와 인증서·TLS 종료 방식은 플랫폼 적용 전에 확정해야 한다. + +## 채택하지 않은 대안 + +**하나의 MCP 프로세스가 모든 path를 처리한다.** 배포 수는 줄지만 전역 장애 범위와 noisy neighbor가 생기고, +route별 Registry·cache·readiness를 새로 구현해야 하므로 채택하지 않았다. + +**Tool Service가 MCP protocol을 직접 구현하고 중앙 Gateway가 raw proxy한다.** Tool Service 계약과 책임이 +크게 바뀌며 현재의 공통 MCP 검증 계층이 중복되므로 채택하지 않았다. diff --git a/docs/decisions/ADR-0009-container-handles-public-mcp-path.md b/docs/decisions/ADR-0009-container-handles-public-mcp-path.md new file mode 100644 index 0000000..da305c3 --- /dev/null +++ b/docs/decisions/ADR-0009-container-handles-public-mcp-path.md @@ -0,0 +1,36 @@ +# ADR-0009 컨테이너가 공개 MCP path를 직접 처리한다 + +- 상태: Accepted +- 결정일: 2026-08-05 +- 대체: [ADR-0008](ADR-0008-shared-host-path-routing.md) +- 관련: [ADR-0007](ADR-0007-one-mcp-per-tool-service.md) + +## 배경 + +Agent Builder는 `/mcp/core`, `/mcp/process`, `/mcp/information`처럼 sub path까지 포함한 URL을 각각의 MCP로 등록한다. 각 URL은 독립 MCP 컨테이너와 Tool Service에 연결된다. 공개 path를 OpenShift Route가 내부 `/mcp`로 바꾸면 Agent Builder가 등록한 경로와 컨테이너가 처리한 경로가 달라져 운영 추적과 설정 검증이 어려워진다. + +## 결정 + +1. `deployments..publicPath`는 Agent Builder 등록 URL과 컨테이너 endpoint가 함께 사용하는 단일 정본이다. +2. OpenShift Route는 host와 path로 독립 MCP Service만 선택하고 path를 rewrite하지 않는다. +3. Helm ConfigMap이 `publicPath`를 `mcp.endpoint-path`로 주입하고, Controller와 Filter가 그 경로만 처리한다. +4. MCP 컨테이너 하나는 endpoint와 Tool Service를 각각 하나만 가진다. 하나의 Java 프로세스에서 path별 Registry를 선택하지 않는다. +5. 요청 body·header·Tool name은 컨테이너 선택에 관여하지 않는다. + +```text +https://{host}/mcp/core -> core MCP /mcp/core -> core Tool Service +https://{host}/mcp/process -> process MCP /mcp/process -> process Tool Service +https://{host}/mcp/information -> information MCP /mcp/information -> information Tool Service +``` + +## 영향 + +- 같은 host에서 여러 Service를 사용하므로 OpenShift Route의 path 기반 Service 선택은 유지한다. +- rewrite annotation은 사용하지 않는다. access log와 애플리케이션 log가 같은 path를 본다. +- `publicPath` 변경은 Route와 애플리케이션 endpoint를 함께 바꾸며 Agent Builder 등록 정보도 갱신해야 한다. +- Registry snapshot, Redis namespace, readiness, connection pool과 장애 범위는 배포별로 계속 분리된다. +- JSON-RPC payload와 Tool Service wire 계약은 바뀌지 않는다. + +## 대체한 결정 + +ADR-0008의 공유 host와 독립 Deployment 원칙은 유지한다. 공개 path를 내부 `/mcp`로 rewrite한다는 부분만 이 ADR이 대체한다. diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..4244e76 --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,23 @@ +# Architecture Decision Records + +장기 설계 결정은 ADR로 관리한다. + +- `Accepted`: 확정된 결정 +- `Superseded`: 후속 ADR로 대체된 결정 +- `Rejected`: 검토했으나 채택하지 않은 결정 + +결정이 변경되면 기존 ADR을 삭제하거나 의미를 덮어쓰지 않고 새 ADR에서 대체 관계를 기록한다. + +## 결정 목록 + +| ADR | 제목 | 상태 | +|---|---|---| +| [ADR-0001](ADR-0001-stateless-execution-boundary.md) | Stateless 실행 책임 경계 | Accepted | +| [ADR-0002](ADR-0002-tool-exposure-and-single-call.md) | Agent Builder Tool 노출 제한과 단일 Tool 호출 | Accepted | +| [ADR-0003](ADR-0003-builder-tool-uid.md) | Builder Tool UID를 시스템 간 식별 키로 사용 | Superseded | +| [ADR-0004](ADR-0004-execution-guardrails.md) | 300초, Raw Data, unsafe retry 실행 가드레일 | Accepted | +| [ADR-0005](ADR-0005-standard-tool-name.md) | 표준 MCP Tool name을 실행 식별자로 사용 | Accepted | +| [ADR-0006](ADR-0006-no-authentication-in-mcp.md) | MCP Server는 인증·인가를 하지 않는다 | Accepted | +| [ADR-0007](ADR-0007-one-mcp-per-tool-service.md) | MCP 배포 하나는 Tool Service 하나만 본다 | Accepted | +| [ADR-0008](ADR-0008-shared-host-path-routing.md) | 공유 host의 path를 독립 MCP 배포로 연결 | Superseded | +| [ADR-0009](ADR-0009-container-handles-public-mcp-path.md) | 컨테이너가 공개 MCP path를 직접 처리 | Accepted | diff --git a/docs/discussions/DISC-20260710-001-agentbuilder-mcp-interface.md b/docs/discussions/DISC-20260710-001-agentbuilder-mcp-interface.md new file mode 100644 index 0000000..6d8abdc --- /dev/null +++ b/docs/discussions/DISC-20260710-001-agentbuilder-mcp-interface.md @@ -0,0 +1,50 @@ +# DISC-20260710-001 Agent Builder-MCP 인터페이스 협의 기록 + +- 상태: Closed / Historical +- 회의 기준일: 2026-07-10 +- 원본: `20260710_AgentBuilder_MCP_통신규약분석협의_회의록.pptx` +- 원본 위치: 저장소 외부의 통제된 AX HUB 업무 자료 영역 +- 보안 등급: 내부용 + +이 문서는 당시 논의의 출발점만 보존한다. 현재 동작이나 미합의 사항의 정본으로 사용하지 않는다. + +## 현재 정본 + +| 주제 | 정본 | +|---|---| +| 현재 Agent Builder wire 계약 | [동기 JSON v0.3](../contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md) | +| Stateless 책임 | [ADR-0001](../decisions/ADR-0001-stateless-execution-boundary.md) | +| 단일 Tool 실행 | [ADR-0002](../decisions/ADR-0002-tool-exposure-and-single-call.md) | +| 실행 식별자 | [ADR-0005](../decisions/ADR-0005-standard-tool-name.md) | +| Timeout·retry | [ADR-0004](../decisions/ADR-0004-execution-guardrails.md), [요청 시간 예산](../architecture.md#요청-시간-예산) | +| 인증·인가 경계 | [ADR-0006](../decisions/ADR-0006-no-authentication-in-mcp.md) | +| 아직 합의하지 않은 사항 | [extension-points.md](../extension-points.md) | + +## 회의 당시 확인한 방향 + +1. MCP Server는 stateless 실행 계층이며 Tool을 자율 선택하지 않는다. +2. Agent Builder가 LLM에 노출할 Tool을 선택하고 최대 50개로 제한한다. +3. 요청 하나는 Tool 하나만 실행한다. +4. 전체 상호작용은 300초를 넘기지 않는다. +5. MCP는 3만 자라는 임의 기준으로 Tool 원문 응답을 자르지 않는다. +6. 멱등성이 확인되지 않은 write/update Tool은 자동 retry하지 않는다. + +## 회의 이후 달라진 결정 + +- 회의에서 검토한 Builder Tool UID는 MCP wire 식별자로 사용하지 않는다. 표준 MCP `name`을 사용한다. +- 당시 예시의 `tool/call`, `toolId/contract`, `toolCalls[]`는 현재 계약이 아니다. 현재는 표준 `tools/call`의 `params.name`과 `params.arguments`를 사용한다. +- MCP의 JWT decode·인증·인가 구현은 제거했다. NetworkPolicy가 호출자를 제한하고, Agent Builder와 Tool Service가 각자의 권한을 집행한다. +- MCP 요청 deadline 기본값은 270초이고 Tool timeout 상한은 30초다. 300초는 Agent Builder를 포함한 전체 상한이다. +- streaming 실행 경로는 제거했다. 기존 `Accept: application/json, text/event-stream`은 수용하지만 응답은 단일 `application/json`이다. + +## 남은 논의의 관리 위치 + +오류 노출, cancellation, retry와 멱등성, 대용량 응답, 식별자 lifecycle, 운영 보완 사항은 +[extension-points.md](../extension-points.md)에서만 관리한다. 이 문서에는 이후 상태를 중복 기록하지 않는다. + +## 변경 이력 + +| 일자 | 내용 | +|---|---| +| 2026-07-13 | 회의 자료 분석과 초기 변경 범위 기록 | +| 2026-08-01 | 후속 ADR·현재 계약이 확정되어 역사 문서로 종결하고 정본 링크만 유지 | diff --git a/docs/discussions/README.md b/docs/discussions/README.md new file mode 100644 index 0000000..67ffd31 --- /dev/null +++ b/docs/discussions/README.md @@ -0,0 +1,14 @@ +# 협의 및 검토 기록 + +이 디렉터리는 회의, 검토, 분석 당시의 사실과 쟁점을 보존한다. + +- 협의 기록은 최종 인터페이스 규격이 아니다. +- 확정된 장기 설계 결정은 `../decisions/`의 ADR로 분리한다. +- 현재 구현 계약과 목표 계약은 `../contracts/`에서 관리한다. +- 후속 협의가 끝나면 원문을 덮어쓰지 않고 관련 ADR과 계약 문서 링크를 추가한다. + +## 기록 목록 + +| ID | 제목 | 상태 | 기준일 | +|---|---|---|---| +| [DISC-20260710-001](DISC-20260710-001-agentbuilder-mcp-interface.md) | Agent Builder-MCP 인터페이스 협의 분석 | Open | 2026-07-10 | diff --git a/docs/extension-points.md b/docs/extension-points.md new file mode 100644 index 0000000..8d2e131 --- /dev/null +++ b/docs/extension-points.md @@ -0,0 +1,92 @@ +# 미합의 항목과 확장 포인트 + +이 문서는 **아직 결정되지 않았거나 운영 적용 전에 보완할 내용만** 관리한다. 현재 동작 설명은 [architecture.md](architecture.md), Agent Builder wire 계약은 [v0.3](contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md), Tool Service wire 계약은 [bundle 조회 v0.2](contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md)가 정본이다. + +결정이 끝난 항목은 ADR 또는 현재 계약으로 옮기고 이 목록에서 제거한다. 목표안인 [Agent Builder-MCP v1 기준선](contracts/agent-builder-mcp/protocol-v1-agreement-baseline.md)은 전체 payload가 승인되기 전까지 구현 근거가 아니다. + +## Agent Builder와 합의할 항목 + +1. 여러 MCP protocol version 공존 시 fallback·upgrade와 공지 정책 +2. Tool name 변경·폐기 시 구·신 이름의 rolling 호환 기간과 Agent 재등록 정책 +3. Agent Builder가 `tools/list`를 다시 읽는 **주기**. 주기적으로 읽는다는 것까지는 확인됐고 값은 미정이다. + Agent Builder는 MCP 등록 시점에 Tool 정보를 자기 DB에 저장해 계속 사용하므로, Tool 변경이 실제로 반영되기까지 + 걸리는 시간은 **MCP의 매니페스트 갱신 주기 + Agent Builder의 조회 주기**다. 두 값을 각자 정하면 합이 얼마인지 + 아무도 모르게 되므로 함께 정한다 +4. Agent Builder 내부 UID와 표준 MCP `name`의 lifecycle. UID는 MCP wire 계약에 포함하지 않음 +5. Agent별 최대 50개 Tool 선별 로직과 권한 거부 시 Agent Builder가 사용자에게 보일 응답 +6. client disconnect 시 downstream cancellation 계약. 현재 MCP는 이미 시작한 Tool 호출을 취소하지 않는다 + (계층별 budget 배분은 [architecture.md의 요청 시간 예산](architecture.md#요청-시간-예산)에서 확정) +7. Tool 원본 오류·업무 코드·PII를 Agent Builder에 노출하거나 마스킹하는 기준 +8. response 크기, pagination/continuation과 대용량 결과 정책 +9. retry가 같은 업무 요청인지 판별하는 규칙과 `guid` 재사용 여부. 같은 `guid`를 재사용하기로 합의한 뒤에만 Tool Service의 멱등성 키로 사용 +10. `employee-no`·`virtual-employee-no`가 둘 다 없는 요청을 Agent Builder가 보낼 수 있는지, 언젠가 필수로 승격할지 +11. **주기 `tools/list`가 실패했을 때 DB의 Tool 정보를 어떻게 처리하는가.** 직전 목록을 유지하는지, 비우는지에 따라 + MCP 재기동·배포 중 수십 초 공백이 Agent에 그대로 드러날 수 있다. 중요 등급 MCP의 replica 하한과 PodDisruptionBudget은 + 이 답과 무관하게 [ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md)에서 이미 강제하지만, + 답에 따라 비중요 등급의 배포 방식도 달라진다 +12. 같은 환경 host의 여러 공개 path를 Agent Builder에 등록·변경·폐기하는 절차와 주체. path 처리는 + [ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)로 확정했지만, 배포 수가 Tool Service 수와 같아 + 10~20개 이상일 때의 등록 자동화는 미정이다 + +인증 주체는 [ADR-0006](decisions/ADR-0006-no-authentication-in-mcp.md)에서 확정했다. MCP는 인증·인가를 하지 않는다. + +## Tool Service와 합의할 항목 + +1. `GET {manifestUrl}` 제공, 인증 방식과 NetworkPolicy 범위 +2. Tool name namespace, 변경·폐기 절차와 하위 호환 기간 +3. 허용할 JSON Schema 2020-12 keyword, 원격 `$ref`와 `format` 정책 +4. Tool별 timeout, 권한 scope, write Tool의 idempotency 보장 +5. `outputSchema`/`structuredContent` 도입 여부와 응답 검증 실패 의미 +6. 매니페스트 revision·ETag/304 및 즉시 refresh 알림의 필요성 +7. 사원 식별자 검증 방식: KMS 키 배포 범위(위조 방지 가능 여부), 동일 암호문 재사용 허용 여부, 만료·nonce 도입 여부. + [ADR-0006](decisions/ADR-0006-no-authentication-in-mcp.md) 전제 2가 이 항목에 의존한다 +8. Tool 이름의 **전역 유일성 보장 방법**. 등급으로 나뉜 두 Tool Service가 같은 업무 `namePrefix`를 공유하므로 + (`처리계-중요`와 `처리계-비중요`가 모두 `processing.`), 그 안에서 이름이 겹치지 않게 하는 것은 Tool Service 책임이다. + MCP는 자기 bundle의 prefix만 검증하며 다른 MCP의 이름을 알지 못한다([ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md)) +9. Tool의 **가용성 등급 분류 기준과 변경 절차**. 이 문서에서 위험도는 보안 정책이 아니라 중단 시 업무 + 영향도를 뜻한다. 등급이 바뀌면 그 Tool을 제공하는 MCP endpoint가 바뀌므로 Agent Builder 반영이 + 필요하다. 자주 바뀌지 않는 값으로 다룰 수 있는지 확인한다 + +MCP와 Tool Service를 1:1로 묶는 결정은 [ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md)에서 확정했다. + +현재 서버는 `tools/call` 결과를 `content[0].text`로만 반환한다. `structuredContent`를 지원하기 전까지 운영 매니페스트에는 `outputSchema`를 사용하지 않는다. + +## 플랫폼·DevOps와 확인할 항목 + +배포 정의를 이 저장소가 어디까지 소유하는지 확정되지 않았다. +현재는 [Helm Chart](../deploy/helm/mcp-server/)만 두고 있으며, 빌드·배포 실행 방식은 정의하지 않는다. + +1. **Helm Chart를 어디에 두는가.** 앱 저장소인가 배포 전용 저장소인가 +2. 환경별 namespace 명명 규칙과 Agent Builder namespace. + 후자는 Route를 우회한 Pod 직접 접근의 허용 출처이므로 [ADR-0006](decisions/ADR-0006-no-authentication-in-mcp.md)의 전제와 직결된다 +3. 사내 Nexus에 `io.modelcontextprotocol.sdk:mcp-json-jackson3:2.0.0`과 Spring Boot 4.0.7이 있는가. + 없으면 라이브러리 반입이 선행되어야 한다 +4. 사내 registry의 JDK 21 빌드·실행 이미지 이름. 현재 `Dockerfile`은 외부 이미지를 쓴다 +5. 소스 개행 표준(CRLF)과 `gradlew`의 관계. + shell script가 CRLF이면 Linux 컨테이너에서 실행되지 않으므로 예외 또는 우회 방식이 필요하다 +6. 환경별 실제 `global.mcpHost`, 인증서와 TLS termination 책임 +7. Agent Builder의 실제 고정 egress CIDR과 Route `ip_allowlist` 값 +8. 대상 OpenShift의 ingress namespace label과 IngressController endpoint publishing 방식이 Chart의 NetworkPolicy 전제와 맞는지 + +## 운영 적용 전 필수 보완 + +| 영역 | 현재 상태 | 필요한 결정·구현 | +|---|---|---| +| egress | 절대 HTTP(S) 여부만 검증 | host allowlist, redirect·DNS rebinding 방어, mTLS/NetworkPolicy | +| 장애 격리 | timeout과 last-good 제공 | 측정 후 bulkhead·circuit breaker·제한적 retry 결정 | +| 관측성 | 경계 로그와 bundle Actuator 제공 | Micrometer/OpenTelemetry/SIEM 지표와 경보 기준 | +| 감사 | 일반 애플리케이션 로그만 제공 | 보존 대상·기간·암호화·위변조 방지·유실 정책 확정 후 durable sink | +| 용량 | request body 1 MiB 제한 | response 크기, JSON depth, 동시 실행 수, connection pool 부하 기준 | +| Redis | 요청 경로 밖의 선택 cache. 현재 코드의 key 형식·TTL·활성 기본값은 임시 구현값 | Redis 사용 여부, key namespace·schema version·TTL·공유 범위, TLS/ACL, Sentinel/Cluster, rolling upgrade 정책 | +| 종료 | Spring graceful shutdown | 신규 요청 차단과 진행 중 Tool 호출 drain 검증 | +| 가용성 | test·prod critical의 replica·PDB·노드 분산 values를 정적 테스트가 검사하고 usable snapshot으로 readiness 판정. 공개 Route도 배포마다 분리 | `helm lint/template`, 노드 분산 실제 확인, 배포 창 분리, 쿼터 산정. 공유 ingress·DNS 장애는 path 분할로 막히지 않는다 | + +## 코드 확장 경계 + +- 새 MCP method: `McpMethodHandlerRegistry.Handler` 구현 하나를 추가하고 SDK method 상수를 사용한다. +- Tool metadata: 새 원천을 만들지 말고 local fixture 또는 Tool Service 매니페스트 계약을 확장한다. +- Tool protocol: 실제 두 번째 protocol이 필요할 때만 `ToolClient` 구현을 추가한다. +- 인증: 추가하지 않는다. 필요가 생기면 [ADR-0006](decisions/ADR-0006-no-authentication-in-mcp.md)을 대체하는 ADR을 먼저 쓴다. +- 규제 감사: 저장·전달 보장이 합의된 뒤 HTTP/Tool 경계에 durable sink를 연결한다. + +새 interface, mapper, DTO, cache 계층은 현재 경계로 해결할 수 없는 요구가 확인되기 전에는 추가하지 않는다. diff --git a/docs/mcp-java-sdk-adoption.md b/docs/mcp-java-sdk-adoption.md new file mode 100644 index 0000000..877dbdb --- /dev/null +++ b/docs/mcp-java-sdk-adoption.md @@ -0,0 +1,144 @@ +# MCP Java SDK 선택적 도입 설계 + +- 상태: 적용 완료 +- 적용 버전: `io.modelcontextprotocol.sdk:mcp-json-jackson3:2.0.0` +- 대상 런타임: Java 21, Spring Boot 4.0.7 +- 적용 원칙: 외부 계약과 AX HUB 고유 실행 경계는 유지하고, 표준 프로토콜 모델과 JSON Schema 검증만 SDK에 위임한다. + +## 1. 도입 결론 + +이 프로젝트는 MCP Java SDK의 서버 Starter나 HTTP transport를 사용하지 않는다. 현재 `/mcp` endpoint는 +Agent Builder와 합의한 동기 JSON, `Mcp-Session-Id`, protocol version HTTP 400, trace 계약을 이미 구현하고 +있으므로 SDK transport를 함께 활성화하면 같은 endpoint에 두 프로토콜 처리 경로가 생길 수 있기 때문이다. + +대신 실제 사용 모듈인 `mcp-json-jackson3`에 직접 의존한다. 이 모듈이 `mcp-core`를 전이 제공하므로 aggregate artifact를 별도로 선언하지 않는다. 적용 범위는 다음과 같다. + +| 적용 영역 | SDK 타입/기능 | 기존 코드에서의 사용 위치 | +|---|---|---| +| MCP method 이름 | `McpSchema.METHOD_*` | handler와 protocol validator | +| JSON-RPC 버전과 표준 오류 번호 | `McpSchema.JSONRPC_VERSION`, `McpSchema.ErrorCodes` | request parser, response, error enum | +| initialize 결과 | `McpSchema.InitializeResult`, `Implementation`, `ServerCapabilities` | `InitializeHandler` | +| tools/list 결과 | `McpSchema.ListToolsResult`, `Tool` | `ToolsListHandler` | +| tools/call 결과 | `McpSchema.CallToolResult`, `TextContent` | `ToolsCallHandler` | +| Tool arguments schema 검증 | `DefaultJsonSchemaValidator` | `ToolArgumentValidator` | + +이 서버는 Spring AI API를 사용하지 않으므로 Spring AI BOM을 두지 않는다. MCP SDK 버전은 `2.0.0`으로 직접 +고정한다. Starter를 추가하지 않았기 때문에 Spring AI MCP auto-configuration, 별도 `/mcp` mapping, +SSE/Streamable transport bean은 생성되지 않는다. + +## 2. 전체 요청 경계 + +```text +Agent Builder + -> 기존 McpExchangeFilter + - 호출자 header 추출 (guid, request-id, session, 사원 식별자) + - guid, x-request-id, MCP Session correlation + - protocol version HTTP header 검증 + - HTTP/Tool 경계 trace log + -> 기존 McpController + -> 기존 JsonRpcRequestParser / McpMethodHandlerRegistry + -> Handler + - initialize: SDK InitializeResult 생성 + - tools/list: 기존 Registry metadata -> SDK Tool/ListToolsResult + - tools/call: 기존 실행 결과 -> SDK CallToolResult/TextContent + -> 기존 JsonRpcResponse envelope + -> application/json 응답 +``` + +Tool 실행 경로는 다음과 같다. + +```text +tools/call + -> 기존 ToolsCallHandler params 검증 + -> 기존 ToolRegistryService + -> 기존 basic contract + SDK JSON Schema 검증 + -> SDK JSON Schema 2020-12 검증 + -> 기존 ToolRoutingService / ToolClient + -> 기존 timeout·correlation 처리 (MCP는 인증·인가하지 않음) +``` + +SDK 모델은 handler의 표준 MCP payload를 만드는 데만 사용한다. SDK server가 요청을 dispatch하거나 Tool Service를 +호출하지 않는다. + +## 3. SDK와 기존 소스의 책임 경계 + +### SDK에 위임한 책임 + +- 표준 MCP method 문자열과 JSON-RPC 상수 +- initialize, Tool definition, list result, call result의 표준 필드 구조 +- text content의 `type: "text"` 표현 +- JSON Schema 2020-12 기반 arguments 검증과 schema 컴파일 재사용 +- `minLength`, `pattern`, `enum`, `additionalProperties`, 중첩 객체 등 기존 기본 validator보다 넓은 schema keyword + +### 기존 구현이 계속 소유하는 책임 + +- `/mcp` HTTP mapping과 항상 `application/json`을 반환하는 transport 정책 +- `Accept: application/json, text/event-stream` 수용 +- `Mcp-Session-Id` 생성과 stateless correlation +- `MCP-Protocol-Version` 누락·미지원 시 HTTP 400 처리 +- JSON-RPC parse/invalid request/invalid params의 현재 오류 envelope와 메시지 +- Tool Service 매니페스트 pull, Redis/memory fallback, local fixture +- Tool endpoint/timeout/version 진단 정보와 dynamic metadata +- 호출자 header 추출·검증과 Tool Service bypass 정책 (인증·인가는 하지 않음, ADR-0006) +- `guid`·`x-request-id` 전파, 요청 context 정리, HTTP/Tool 경계 로그 +- Tool 선택 금지, exactly-one Tool 실행, outbound routing과 업무 오류 변환 + +## 4. 기존 계약 보존 방법 + +| 위험 | 회피 방식 | +|---|---| +| SDK Starter가 기존 `/mcp`와 충돌 | Starter를 사용하지 않고 core 모델과 Jackson 3 validator만 의존 | +| SDK가 Tool 검증 실패를 `isError=true`로 바꿈 | SDK server handler를 사용하지 않고 검증 실패를 기존 `-32602 Invalid params`로 변환 | +| 기존 required/type 오류 문구 변경 | 공개된 기본 검증을 SDK보다 먼저 실행해 기존 메시지를 그대로 유지 | +| `Mcp-Session-Id`/protocol header 동작 변경 | 기존 filter, controller, validator를 유지 | +| `tools/call` 결과 필드 위치 변경 | 직렬화 계약 테스트로 `content[0]._meta.searchTime`과 `isError`를 고정 | +| local tools/list 공개 필드 누락 | SDK `Tool` 변환 전 `_meta`만 제거하고 title/outputSchema/annotations를 직렬화 비교 | +| Registry의 기존 Tool에 inputSchema 누락 | SDK 필수 조건을 만족하는 빈 object schema로 정규화 | +| dynamic Registry를 annotation Tool로 고정 | `@McpTool`을 사용하지 않고 요청마다 기존 Registry service를 조회 | +| correlation·Trace 기능이 SDK 내부로 사라짐 | transport와 실행 orchestration을 기존 코드에 유지 | +| SDK 업그레이드로 wire payload 변경 | SDK 버전을 명시하고 initialize/list/call golden serialization 테스트를 통과한 경우에만 변경 | + +## 5. JSON Schema 검증 정책 + +SDK 검증은 `ToolExecutionService`가 Registry 기반 argument validation을 수행하는 위치에 연결했다. +검증 순서는 다음과 같다. + +1. 기존 object/required/basic type 검증으로 공개된 오류 문구를 보존한다. +2. SDK JSON Schema validator로 나머지 2020-12 keyword를 검증한다. +3. 실패하면 Tool Service를 호출하지 않고 기존 `JsonRpcException(INVALID_PARAMS)`으로 종료한다. SDK 원문 오류는 + 입력값을 포함할 수 있으므로 외부에는 `arguments do not match inputSchema`만 반환한다. + +SDK validator는 Spring singleton으로 한 번 생성되며 동일 schema의 컴파일 결과를 재사용한다. Registry가 제공하는 +schema 자체의 허용 dialect와 `$ref` 원격 해석 정책은 운영 Registry 계약으로 별도 통제해야 한다. + +## 6. 의도적으로 도입하지 않은 SDK 기능 + +- `spring-ai-starter-mcp-server-webmvc` +- SDK sync/async/stateless server와 transport provider +- SSE 또는 Streamable HTTP 응답 +- SDK authorization/security 구현 +- annotation 기반 정적 `@McpTool` 등록 +- SDK가 소유하는 Tool lifecycle, list-changed notification +- Resources, Prompts, Sampling, Elicitation + +이 기능들은 현재 요구사항을 해결하지 않거나 기존 계약과 중복된다. 실제 필요가 생기면 별도 ADR과 Agent Builder +contract test를 먼저 추가한다. + +## 7. 변경 및 검증 기준 + +SDK 버전을 올릴 때는 다음을 모두 확인한다. + +1. Spring Boot/Java/MCP Java SDK 조합의 dependency resolution +2. SDK `McpSchema` 필드와 Jackson 3 직렬화 변경 여부 +3. initialize, tools/list, tools/call 성공·실패 JSON의 기존 예제 일치 +4. JSON-RPC 오류 code/message/data 및 HTTP status +5. `Accept`에 `text/event-stream`이 있어도 JSON 응답을 유지하는지 +6. `Mcp-Session-Id`, protocol version, `guid`, `x-request-id` 전파 +7. local profile, Registry 장애 fallback, Redis 비필수 동작 +8. `.\gradlew.bat clean test` + +참고: + +- [MCP Java SDK](https://github.com/modelcontextprotocol/java-sdk) +- [Spring AI 2.0 Upgrade Notes](https://docs.spring.io/spring-ai/reference/upgrade-notes.html) +- [Spring AI MCP Server](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..dab2baa --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'ax-hub-mcp-server' diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/McpServerApplication.java b/src/main/java/io/shinhanlife/dap/biz/mcp/McpServerApplication.java new file mode 100644 index 0000000..56f58e3 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/McpServerApplication.java @@ -0,0 +1,38 @@ +package io.shinhanlife.dap.biz.mcp; + +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * AX HUB MCP Server의 Spring Boot 애플리케이션 시작점입니다. HTTP 요청을 직접 처리하지 않고 component scan, configuration properties, scheduler를 활성화해 + * transport·method·registry·execute·observability 구성요소를 조립합니다. 주요 의존성은 Spring Boot 자동 구성, {@code McpProperties} 설정 객체, MCP SDK의 JSON Schema 검증기이며 실행 인자는 Spring + * 컨테이너로 전달됩니다. + */ +@SpringBootApplication +@ConfigurationPropertiesScan +@EnableScheduling +public class McpServerApplication { + + /** + * Spring Boot 애플리케이션을 시작하는 최초 진입점입니다. 전달받은 실행 인자를 Spring에 넘기고 component scan과 설정 로딩을 시작합니다. + */ + public static void main(String[] args) { + SpringApplication.run(McpServerApplication.class, args); + } + + /** + * Tool inputSchema와 arguments를 JSON Schema 2020-12 기준으로 검증할 MCP SDK 검증기를 한 번 생성합니다. Tool 실행 전 검증 계층에서만 사용하며 MCP HTTP transport나 서버 lifecycle을 자동 구성하지 + * 않습니다. + * + * @return schema 컴파일 결과를 재사용하는 MCP SDK 검증기 + */ + @Bean + JsonSchemaValidator mcpJsonSchemaValidator() { + return new DefaultJsonSchemaValidator(); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/config/HttpClientConfig.java b/src/main/java/io/shinhanlife/dap/biz/mcp/config/HttpClientConfig.java new file mode 100644 index 0000000..d02bf97 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/config/HttpClientConfig.java @@ -0,0 +1,45 @@ +package io.shinhanlife.dap.biz.mcp.config; + +import java.net.http.HttpClient; +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +/** + * Tool Service manifest 조회와 Tool 실행에 필요한 Spring/JDK client Bean을 구성하는 설정 클래스입니다. MCP 요청을 직접 처리하지 않으며 discovery와 {@code HttpToolClient}가 주입받을 연결·timeout 기본값을 + * 제공합니다. 주요 의존성은 {@link McpProperties}, Spring RestClient 및 JDK HttpClient입니다. + */ +@Configuration +public class HttpClientConfig { + + /** + * 여러 Tool 호출이 TCP 연결을 재사용할 수 있도록 공유 JDK HTTP client를 만듭니다. Tool별 read timeout은 이 객체를 새로 만들지 않고 HttpToolClient 쪽에서 적용합니다. + */ + @Bean + @Qualifier("toolHttpClient") + HttpClient toolHttpClient(McpProperties properties) { + return HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(properties.toolClient().connectTimeoutMillis())) + .build(); + } + + /** + * Tool Service bundle 매니페스트 조회 전용 RestClient를 생성합니다. 조회 대상이 bundle마다 다르므로 base URL을 두지 않고 매 호출에서 전체 {@code manifestUrl}을 사용합니다. timeout은 Tool 실행보다 짧게 잡아, + * 느린 bundle 하나가 전체 조회 주기를 잡아먹지 않게 합니다. + */ + @Bean + @Qualifier("manifestRestClient") + RestClient manifestRestClient(McpProperties properties) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + McpProperties.Discovery discovery = properties.discovery(); + factory.setConnectTimeout( + Duration.ofMillis(discovery == null ? 1_000 : discovery.connectTimeoutMillis())); + factory.setReadTimeout( + Duration.ofMillis(discovery == null ? 3_000 : discovery.readTimeoutMillis())); + return RestClient.builder().requestFactory(factory).build(); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java b/src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java new file mode 100644 index 0000000..dfb6bda --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java @@ -0,0 +1,173 @@ +package io.shinhanlife.dap.biz.mcp.config; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * {@code application.yml}의 {@code mcp.*} 설정을 타입 안전한 불변 객체로 묶고 시작 시 유효성을 검증하는 구성 계약입니다. MCP 요청을 직접 처리하지 않으며 공개 endpoint, HTTP transport, Registry, Tool client와 + * observability 구성요소가 각자의 설정만 읽습니다. 주요 의존성은 Spring Boot ConfigurationProperties와 Jakarta Validation이며, 중첩 record는 서버·연동·cache·trace 정책을 분리합니다. + */ +@Validated +@ConfigurationProperties(prefix = "mcp") +public record McpProperties( + @NotBlank String identity, + @NotBlank + @Pattern( + regexp = "^/mcp(?:/[a-z0-9-]+)?$", + message = "must be /mcp or /mcp/") + String endpointPath, + @Valid Server server, + @Valid Registry registry, + @Valid ToolClient toolClient, + @Valid Redis redis, + @Valid Trace trace, + @Valid Protocol protocol, + @Valid Discovery discovery, + List<@Valid Bundle> bundles) { + + /** + * 선언되지 않은 bundle 목록을 빈 목록으로 정규화해 이후 코드가 null을 검사하지 않게 합니다. + */ + public McpProperties { + bundles = bundles == null ? List.of() : List.copyOf(bundles); + } + + /** + * 조회 대상으로 켜져 있는 bundle만 골라 반환합니다. 조회·병합·Actuator 상태가 같은 목록을 사용합니다. + */ + public List enabledBundles() { + return bundles.stream().filter(Bundle::enabled).toList(); + } + + /** + * bundle 조회를 켰는데 조회 대상이 하나도 없으면 기동에 실패시킵니다. 이 상태로 기동하면 {@code tools/list}가 영구히 비어 있으므로 운영 중 발견하는 것보다 기동 실패가 낫습니다. + */ + @AssertTrue(message = "mcp.discovery.enabled=true requires at least one entry in mcp.bundles") + public boolean isDiscoveryTargetDeclared() { + return discovery == null || !discovery.enabled() || !enabledBundles().isEmpty(); + } + + /** + * bundle {@code id}와 {@code namePrefix}가 서로 충돌하지 않는지 기동 시 검증합니다. prefix가 다른 prefix의 접두사이면(예: {@code a.}와 {@code a.b.}) Tool 이름이 어느 bundle 소속인지 확정되지 않아 라우팅 + * 대상이 흔들리므로 이 조합을 금지합니다. + */ + @AssertTrue( + message = + "mcp.bundles id and namePrefix must be unique, and no namePrefix may be a prefix of another") + public boolean isBundleRoutingUnambiguous() { + List ids = bundles.stream().map(Bundle::id).toList(); + if (ids.size() != ids.stream().distinct().count()) { + return false; + } + List prefixes = + bundles.stream() + .map(Bundle::namePrefix) + .filter(prefix -> prefix != null && !prefix.isBlank()) + .toList(); + if (prefixes.size() != prefixes.stream().distinct().count()) { + return false; + } + for (String outer : prefixes) { + for (String inner : prefixes) { + if (outer != inner && inner.startsWith(outer)) { + return false; + } + } + } + return true; + } + + /** + * initialize 응답에 공개할 MCP 서버 식별 정보 설정입니다. + */ + public record Server(@NotBlank String name, @NotBlank String title, @NotBlank String version) { + } + + /** + * local JSON fixture 위치와 Tool Service refresh 주기·분산 지연 설정입니다. + */ + public record Registry( + @NotBlank String localToolFile, + @Min(1) long refreshIntervalSeconds, + @Min(0) long refreshJitterSeconds) { + } + + /** + * Tool Service 호출의 timeout과 header 전달 정책 설정입니다. + */ + public record ToolClient( + @Min(1) int connectTimeoutMillis, + @Min(1) int readTimeoutMillis, + @Min(1) long requestDeadlineMillis, + boolean forwardAuthorization) { + } + + /** + * 선택적 Redis Tool Registry cache의 활성화 여부와 key namespace 설정입니다. + */ + public record Redis(boolean enabled, @NotBlank String keyPrefix) { + } + + /** + * Tool Service bundle 매니페스트 주기 조회의 timeout과 상한 정책 설정입니다. 상한값은 잘못 구성된 bundle 하나가 전체 카탈로그를 부풀리거나 Tool timeout을 무한정 늘리는 것을 막는 방어선입니다. + */ + public record Discovery( + boolean enabled, + @Min(1) int connectTimeoutMillis, + @Min(1) int readTimeoutMillis, + @Min(1) int maxToolsPerBundle, + @Min(1) int maxToolsTotal, + @Min(1) int maxManifestBytes, + @Min(1) int maxToolTimeoutMillis) { + } + + /** + * 이 MCP에 속하는 Tool Service 한 묶음의 조회 주소와 실행 주소 설정입니다. {@code baseEndpoint}는 설정에서만 오며 매니페스트 응답이 바꿀 수 없습니다. {@code fallbackManifestFile}은 최초 원격 조회 실패 시에만 쓰는 + * local 검증용 원천입니다. + */ + public record Bundle( + @NotBlank String id, + @NotBlank String manifestUrl, + @NotBlank String baseEndpoint, + @NotBlank String namePrefix, + boolean enabled, + String fallbackManifestFile) { + } + + /** + * MCP 경계 로그 활성화와 수신 요청 최대 크기 정책 설정입니다. + */ + public record Trace(boolean enabled, @Min(1) int maxBodyBytes) { + } + + /** + * initialize 협상 및 이후 요청 헤더 검증에 사용할 MCP protocol version 정책 설정입니다. + */ + public record Protocol( + @NotEmpty List<@NotBlank String> supportedVersions, @NotBlank String preferredVersion) { + + /** + * 외부에서 받은 지원 버전 목록을 복사해 설정 객체가 생성된 뒤 바뀌지 않게 합니다. + */ + public Protocol { + supportedVersions = List.copyOf(supportedVersions); + } + + /** + * preferred version이 실제 지원 목록에도 포함되는지 설정 로딩 시 검증합니다. + */ + @AssertTrue(message = "preferredVersion must be included in supportedVersions") + public boolean isPreferredVersionSupported() { + return supportedVersions.contains(preferredVersion); + } + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContext.java b/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContext.java new file mode 100644 index 0000000..b5e2445 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContext.java @@ -0,0 +1,42 @@ +package io.shinhanlife.dap.biz.mcp.context; + +import java.time.Duration; +import java.time.Instant; + +/** + * 하나의 MCP HTTP 요청 전체에서 공유할 correlation·호출자·deadline 정보를 담는 불변 context입니다. {@link McpRequestContextFactory}가 만들고 HTTP transport, method handler, Tool client, + * observability 계층이 사용하며 서버 대화 상태를 저장하지 않습니다. {@code guid}는 요청 하나를 끝까지 따라가는 상관 값이고 {@code requestId}는 개별 HTTP 요청 식별자입니다. {@code employeeNo}와 + * {@code virtualEmployeeNo}는 호출자가 암호화해 보낸 + * 불투명 값입니다. MCP는 이를 복호화하거나 해석하지 않고 Tool Service로 그대로 전달하기만 하며, 로그에는 절대 남기지 않습니다. + */ +public record McpRequestContext( + String requestId, + String guid, + String mcpSessionId, + String employeeNo, + String virtualEmployeeNo, + String authorization, + Instant deadline) { + + /** + * deadline이 없는 context를 허용하되 이미 만료된 것으로 취급합니다. + * + *

정상 경로에서는 {@link McpRequestContextFactory}가 항상 값을 채우므로 null이 올 수 없습니다. 그래도 null을 현재 시각으로 바꾸는 + * 이유는, 만약 잘못 만들어진 context가 흘러들어오면 {@link #remainingMillis()}가 0 이하가 되어 Tool 호출이 즉시 중단되기 때문입니다. 시간 제한 없이 무한정 호출되는 것보다 안전한 쪽으로 실패합니다. + */ + public McpRequestContext { + deadline = deadline == null ? Instant.now() : deadline; + } + + /** + * 이 요청에 남은 시간을 밀리초로 알려 줍니다. + * + *

Tool 호출 직전마다 계산해, Tool 하나가 자기 timeout을 다 쓰더라도 요청 전체 예산을 넘기지 않도록 read timeout을 깎는 데 씁니다. 이미 + * 시간이 다 됐으면 0 이하가 되고, 그때는 Tool을 호출하지 않고 timeout으로 끝냅니다. + * + *

이 예산은 Agent Builder가 연결을 끊는 시각보다 짧아야 합니다. 같거나 길면 MCP가 응답을 만들어도 받을 상대가 이미 사라진 뒤입니다. + */ + public long remainingMillis() { + return Duration.between(Instant.now(), deadline).toMillis(); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContextHolder.java b/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContextHolder.java new file mode 100644 index 0000000..a87ddeb --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContextHolder.java @@ -0,0 +1,47 @@ +package io.shinhanlife.dap.biz.mcp.context; + +import java.util.Optional; + +/** + * 현재 요청 처리 thread에 {@link McpRequestContext}를 임시로 연결하는 ThreadLocal holder입니다. {@code McpExchangeFilter}가 설정하고 정상·예외 완료 시 제거합니다. 요청 밖에서 context를 보관하거나 서버 세션 상태로 + * 사용하면 안 되는 correlation 전용 유틸리티입니다. + */ +public final class McpRequestContextHolder { + + private static final ThreadLocal CONTEXT = new ThreadLocal<>(); + + /** + * 인스턴스를 만들 수 없는 정적 유틸리티 클래스임을 명확히 합니다. + */ + private McpRequestContextHolder() { + } + + /** + * 현재 요청을 처리하는 thread에 request context를 저장합니다. + */ + public static void set(McpRequestContext context) { + CONTEXT.set(context); + } + + /** + * 현재 thread의 request context를 Optional로 안전하게 조회합니다. + */ + public static Optional get() { + return Optional.ofNullable(CONTEXT.get()); + } + + /** + * 반드시 context가 있어야 하는 처리 단계에서 값을 반환합니다. filter 밖에서 잘못 호출하면 즉시 예외를 발생시켜 잘못된 correlation 처리를 막습니다. + */ + public static McpRequestContext require() { + return get() + .orElseThrow(() -> new IllegalStateException("MCP request context is not available")); + } + + /** + * 요청이 끝난 뒤 ThreadLocal 값을 제거하여 다음 요청에 정보가 섞이지 않게 합니다. + */ + public static void clear() { + CONTEXT.remove(); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidator.java b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidator.java new file mode 100644 index 0000000..f0bb2f1 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidator.java @@ -0,0 +1,121 @@ +package io.shinhanlife.dap.biz.mcp.execute; + +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; + +import java.util.Map; + +import org.springframework.stereotype.Component; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * Registry metadata에 정의된 MCP SDK JSON Schema 2020-12 규칙으로 Tool arguments를 검증합니다. Registry 기반 실행 계획을 만들 때 호출되며, 형식 위반은 upstream Tool Service 호출 전에 Invalid + * params 오류로 끝냅니다. 주요 의존성은 JSON 변환과 크기 계산용 {@link ObjectMapper}, MCP SDK {@link JsonSchemaValidator}, 실행 정책 원천인 {@link ToolMetadata}, 호출 정보인 + * {@link ToolCall}입니다. + */ +@Component +public class ToolArgumentValidator { + + private final ObjectMapper objectMapper; + private final JsonSchemaValidator jsonSchemaValidator; + + /** + * JSON 변환용 Jackson mapper와 MCP SDK JSON Schema 검증기를 주입받습니다. 검증기는 Spring singleton으로 생성되어 동일한 Tool schema 컴파일 결과를 재사용합니다. + */ + public ToolArgumentValidator(ObjectMapper objectMapper, JsonSchemaValidator jsonSchemaValidator) { + this.objectMapper = objectMapper; + this.jsonSchemaValidator = jsonSchemaValidator; + } + + /** + * Registry에서 찾은 Tool의 {@code inputSchema}를 호출 arguments에 적용합니다. {@link ToolExecutionService}가 HTTP routing 전에 호출하므로 실패하면 Tool Service에는 요청이 전송되지 않으며, 위반 + * 내용은 기존 외부 계약인 {@code -32602 Invalid params}로 변환됩니다. + */ + public void validate(ToolCall call, ToolMetadata metadata) { + validateInputSchema(call, metadata.inputSchema()); + } + + /** + * Registry inputSchema를 MCP SDK 검증기에 전달해 JSON Schema 2020-12 keyword를 검사합니다. 기존 외부 계약을 보존하기 위해 검증 실패는 SDK의 Tool result가 아니라 최상위 Invalid params 예외로 변환합니다. + * SDK 원문 오류는 입력값을 포함할 수 있으므로 외부에는 고정된 안전 메시지만 제공합니다. + */ + private void validateInputSchema(ToolCall call, JsonNode schema) { + if (schema == null || schema.isNull()) { + return; + } + validateStableContract(call, schema); + @SuppressWarnings("unchecked") + Map schemaMap = objectMapper.convertValue(schema, Map.class); + Object arguments = objectMapper.convertValue(call.arguments(), Object.class); + JsonSchemaValidator.ValidationResponse validation = + jsonSchemaValidator.validate(schemaMap, arguments); + if (!validation.valid()) { + throw invalid("arguments do not match inputSchema"); + } + } + + /** + * 기존 Agent Builder 계약에 공개된 object·required·기본 type 오류 문구를 SDK 검증 전에 유지합니다. 이 범위 밖의 minLength, pattern, additionalProperties 같은 keyword는 이어지는 SDK 검증기가 + * 담당합니다. + */ + private void validateStableContract(ToolCall call, JsonNode schema) { + if (schema.has("type") && !"object".equals(schema.path("type").asString())) { + throw invalid("Only object inputSchema is supported by this adapter"); + } + JsonNode required = schema.path("required"); + if (required.isArray()) { + required.forEach( + field -> { + String name = field.asString(); + if (!call.arguments().has(name) || call.arguments().get(name).isNull()) { + throw invalid("'" + name + "' is required"); + } + }); + } + JsonNode properties = schema.path("properties"); + if (properties.isObject()) { + properties + .properties() + .forEach( + entry -> { + JsonNode value = call.arguments().get(entry.getKey()); + if (value != null && !value.isNull()) { + validateStableType( + entry.getKey(), entry.getValue().path("type").asString(null), value); + } + }); + } + } + + /** + * 기존 기본 JSON 타입 오류 문구를 보존하면서 각 arguments 값의 선언 타입을 검사합니다. + */ + private void validateStableType(String field, String type, JsonNode value) { + if (type == null) { + return; + } + boolean valid = + switch (type) { + case "string" -> value.isString(); + case "integer" -> value.isIntegralNumber(); + case "number" -> value.isNumber(); + case "boolean" -> value.isBoolean(); + case "object" -> value.isObject(); + case "array" -> value.isArray(); + default -> true; + }; + if (!valid) { + throw invalid(field + " must be of type " + type); + } + } + + /** + * 검증 실패 이유를 Invalid params JSON-RPC 예외로 통일합니다. + */ + private JsonRpcException invalid(String details) { + return new JsonRpcException(JsonRpcErrorCode.INVALID_PARAMS, details); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolCall.java b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolCall.java new file mode 100644 index 0000000..6fd5926 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolCall.java @@ -0,0 +1,10 @@ +package io.shinhanlife.dap.biz.mcp.execute; + +import tools.jackson.databind.JsonNode; + +/** + * MCP {@code tools/call} 요청에서 추출한 도구명과 arguments를 운반하는 불변 값 객체입니다. HTTP 요청을 직접 처리하지 않으며 tools/call handler가 만들고 Tool 실행 계층이 소비합니다. {@code arguments}는 원본 JSON + * 구조를 보존해 이후 schema 검증과 Tool Service 호출에 사용합니다. + */ +public record ToolCall(String toolName, JsonNode arguments) { +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionService.java b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionService.java new file mode 100644 index 0000000..799d194 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionService.java @@ -0,0 +1,108 @@ +package io.shinhanlife.dap.biz.mcp.execute; + +import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.observability.TraceLogger; +import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; +import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService; +import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient; +import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException; +import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest; +import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse; +import org.springframework.stereotype.Service; +import tools.jackson.databind.JsonNode; + +/** + * MCP Tool 실행의 orchestration 서비스입니다. {@code tools/call}의 단일 Tool 실행 단계를 만들고 routing된 HTTP 요청을 실행하며, timeout·권한·실패를 JSON-RPC 내부 오류로 정규화합니다. 주요 의존성은 Registry, + * argument validator, routing service, {@link ToolClient}와 Tool HTTP 호출·응답 경계를 기록하는 trace logger입니다. + */ +@Service +public class ToolExecutionService { + + private final ToolRegistryService registryService; + private final ToolArgumentValidator argumentValidator; + private final ToolRoutingService routingService; + private final ToolClient toolClient; + private final TraceLogger traceLogger; + + /** + * metadata 조회, 입력 검증, HTTP routing, Tool client와 경계 로그 협력 객체를 주입받습니다. + */ + public ToolExecutionService( + ToolRegistryService registryService, + ToolArgumentValidator argumentValidator, + ToolRoutingService routingService, + ToolClient toolClient, + TraceLogger traceLogger) { + this.registryService = registryService; + this.argumentValidator = argumentValidator; + this.routingService = routingService; + this.toolClient = toolClient; + this.traceLogger = traceLogger; + } + + /** + * Agent Builder가 이름으로 지정한 단일 Tool을 조회·검증·routing한 뒤 한 번 실행합니다. 처리 순서는 Registry 조회 → inputSchema 검증 → endpoint/timeout 확정 → ToolClient 호출이며, 호출 전후에는 + * payload를 제외한 Tool 이름·버전·상태·소요 시간만 기록합니다. ToolClient 실패는 실행 종류별 {@link JsonRpcException}으로 바꾸고 최종 {@code isError} 변환은 handler에 맡깁니다. + */ + public Result execute(ToolCall call, McpRequestContext context) { + ToolMetadata metadata = registryService.findEnabledTool(call.toolName()); + argumentValidator.validate(call, metadata); + ToolRequest toolRequest = routingService.route(call, metadata); + traceLogger.event( + "tool_http_request_started", + "toolName", + toolRequest.toolName(), + "version", + toolRequest.version()); + long started = System.nanoTime(); + try { + ToolResponse response = toolClient.execute(toolRequest, context); + double duration = elapsedMillis(started); + traceLogger.event( + "tool_http_response_received", + "toolName", + toolRequest.toolName(), + "statusCode", + response.statusCode(), + "durationMillis", + duration); + return new Result(response.data(), duration); + } catch (ToolClientException exception) { + traceLogger.error("tool_http_request_failed", exception, "toolName", toolRequest.toolName()); + throw mapException(exception, toolRequest); + } + } + + /** + * System.nanoTime 기준 경과 시간을 밀리초 단위로 계산합니다. + */ + private double elapsedMillis(long startedNanos) { + return (System.nanoTime() - startedNanos) / 1_000_000.0d; + } + + /** + * Tool client 실패 종류를 timeout·권한·실행 JSON-RPC 코드로 일관되게 변환합니다. + */ + private JsonRpcException mapException(ToolClientException exception, ToolRequest request) { + JsonRpcErrorCode code = + switch (exception.kind()) { + case TIMEOUT -> JsonRpcErrorCode.TOOL_TIMEOUT; + case UNAUTHORIZED -> JsonRpcErrorCode.UNAUTHORIZED; + case FORBIDDEN -> JsonRpcErrorCode.FORBIDDEN; + case EXECUTION -> JsonRpcErrorCode.TOOL_EXECUTION_ERROR; + }; + return new JsonRpcException( + code, + request.toolName() + "@" + request.version() + ": " + exception.getMessage(), + exception); + } + + /** + * Tool Service가 반환한 정규화된 본문과 MCP가 측정한 실행 시간을 handler에 전달하는 불변 결과입니다. {@link io.shinhanlife.dap.biz.mcp.method.ToolsCallHandler}가 이를 MCP text content와 + * {@code searchTime}으로 변환합니다. + */ + public record Result(JsonNode data, double durationMillis) { + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingService.java b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingService.java new file mode 100644 index 0000000..332ed25 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingService.java @@ -0,0 +1,68 @@ +package io.shinhanlife.dap.biz.mcp.execute; + +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; +import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest; + +import java.net.URI; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Service; + +/** + * Registry에서 확정된 Tool metadata를 실제 {@link ToolClient} 호출용 HTTP 요청으로 변환하는 routing 서비스입니다. 확정된 Tool metadata에 대해서만 동작하며, AgentBuilder 대신 Tool을 선택하거나 업무 규칙을 판단하지 + * 않습니다. 주요 의존성은 {@link ToolCall}, {@link ToolMetadata}와 {@link io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest} 계약입니다. + */ +@Service +public class ToolRoutingService { + + private static final Pattern TOOL_NAME = Pattern.compile("[A-Za-z0-9_./-]{1,64}"); + private final McpProperties properties; + + /** + * Tool별 timeout이 없을 때 사용할 공통 Tool client 설정을 주입받습니다. + */ + public ToolRoutingService(McpProperties properties) { + this.properties = properties; + } + + /** + * 검증된 호출과 metadata를 실제 HTTP 호출에 사용할 ToolRequest로 변환합니다. 절대 HTTP(S) endpoint와 Tool 이름을 검증하고 {@code POST {baseEndpoint}/{toolName}} 주소를 확정합니다. + */ + public ToolRequest route(ToolCall call, ToolMetadata metadata) { + String endpoint = metadata.endpoint(); + validateEndpoint(endpoint, metadata.name()); + if (metadata.name() == null || !TOOL_NAME.matcher(metadata.name()).matches()) { + throw new JsonRpcException( + JsonRpcErrorCode.INVALID_PARAMS, + "params.name must contain 1-64 letters, digits, underscore, hyphen, dot, or slash"); + } + endpoint = endpoint.replaceAll("/+$", "") + "/" + metadata.name(); + return new ToolRequest( + metadata.name(), + metadata.version(), + endpoint, + call.arguments().deepCopy(), + metadata.effectiveTimeoutMillis(properties.toolClient().readTimeoutMillis())); + } + + /** + * Tool endpoint가 절대 HTTP(S) URL인지 검사해 내부망 상대 경로나 다른 scheme 호출을 막습니다. + */ + private void validateEndpoint(String endpoint, String toolName) { + try { + URI uri = URI.create(endpoint); + if (!uri.isAbsolute() + || !("http".equals(uri.getScheme()) || "https".equals(uri.getScheme()))) { + throw new IllegalArgumentException("endpoint must be absolute HTTP(S)"); + } + } catch (RuntimeException exception) { + throw new JsonRpcException( + JsonRpcErrorCode.TOOL_EXECUTION_ERROR, + "Invalid Tool endpoint for " + toolName, + exception); + } + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcErrorCode.java b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcErrorCode.java new file mode 100644 index 0000000..c9e258d --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcErrorCode.java @@ -0,0 +1,46 @@ +package io.shinhanlife.dap.biz.mcp.jsonrpc; + +import io.modelcontextprotocol.spec.McpSchema; + +/** + * 이 서버가 JSON-RPC error envelope에 사용할 표준 및 서버 내부 확장 오류 코드를 정의합니다. 요청을 직접 처리하지 않으며 validator, registry, 실행 계층이 발생시킨 오류를 exception handler와 error factory가 일관된 + * 숫자·메시지로 직렬화하도록 하는 공통 계약입니다. 표준 JSON-RPC 숫자는 MCP SDK 상수를 사용하고, Tool 실행·Registry·권한 오류만 이 서버의 확장 범위로 유지합니다. + */ +public enum JsonRpcErrorCode { + PARSE_ERROR(McpSchema.ErrorCodes.PARSE_ERROR, "Parse error"), + INVALID_REQUEST(McpSchema.ErrorCodes.INVALID_REQUEST, "Invalid Request"), + METHOD_NOT_FOUND(McpSchema.ErrorCodes.METHOD_NOT_FOUND, "Method not found"), + INVALID_PARAMS(McpSchema.ErrorCodes.INVALID_PARAMS, "Invalid params"), + INTERNAL_ERROR(McpSchema.ErrorCodes.INTERNAL_ERROR, "Internal error"), + TOOL_EXECUTION_ERROR(-32000, "Tool execution error"), + TOOL_NOT_FOUND(-32001, "Tool not found"), + TOOL_TIMEOUT(-32002, "Tool timeout"), + TOOL_REGISTRY_UNAVAILABLE(-32003, "Tool registry unavailable"), + UNAUTHORIZED(-32004, "Unauthorized"), + FORBIDDEN(-32005, "Forbidden"); + + private final int code; + private final String message; + + /** + * 숫자 오류 코드와 외부에 표시할 표준 메시지를 한 쌍으로 저장합니다. + */ + JsonRpcErrorCode(int code, String message) { + this.code = code; + this.message = message; + } + + /** + * JSON-RPC error 객체에 기록할 숫자 코드를 반환합니다. + */ + public int code() { + return code; + } + + /** + * JSON-RPC error 객체에 기록할 안전한 기본 메시지를 반환합니다. + */ + public String message() { + return message; + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcException.java b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcException.java new file mode 100644 index 0000000..f24a109 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcException.java @@ -0,0 +1,60 @@ +package io.shinhanlife.dap.biz.mcp.jsonrpc; + +import tools.jackson.databind.JsonNode; + +/** + * 처리 계층에서 JSON-RPC 오류 코드·안전한 상세 정보·원 요청 ID를 함께 전달하기 위한 런타임 예외입니다. transport, registry, execute 계층이 이 예외를 발생시키고, {@code McpController} 또는 + * {@code McpExceptionHandler}가 JSON-RPC error 응답으로 변환합니다. 주요 의존성은 {@link JsonRpcErrorCode}와 응답 correlation을 위한 JSON 요청 ID이며, HTTP 응답을 직접 만들지 않습니다. + */ +public class JsonRpcException extends RuntimeException { + + private final JsonRpcErrorCode errorCode; + private final Object errorData; + private final JsonNode requestId; + + /** + * 오류 코드와 간단한 상세 설명만으로 JSON-RPC 예외를 만듭니다. + */ + public JsonRpcException(JsonRpcErrorCode errorCode, String details) { + this(errorCode, details, null, null); + } + + /** + * 원인 예외를 함께 보존해야 할 때 사용하는 생성자입니다. + */ + public JsonRpcException(JsonRpcErrorCode errorCode, String details, Throwable cause) { + this(errorCode, details, null, cause); + } + + /** + * 오류 코드, 응답 data, 원 요청 ID, 원인 예외를 모두 지정합니다. requestId를 보존하면 실패 응답도 어떤 JSON-RPC 요청에서 발생했는지 연결할 수 있습니다. + */ + public JsonRpcException( + JsonRpcErrorCode errorCode, Object errorData, JsonNode requestId, Throwable cause) { + super(errorData == null ? errorCode.message() : String.valueOf(errorData), cause); + this.errorCode = errorCode; + this.errorData = errorData; + this.requestId = requestId; + } + + /** + * 표준 JSON-RPC 오류 종류를 반환합니다. + */ + public JsonRpcErrorCode errorCode() { + return errorCode; + } + + /** + * 오류 응답의 data 영역에 넣을 안전한 상세 정보를 반환합니다. + */ + public Object errorData() { + return errorData; + } + + /** + * 실패한 원 요청의 JSON-RPC id를 반환합니다. + */ + public JsonNode requestId() { + return requestId; + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequest.java b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequest.java new file mode 100644 index 0000000..4a8e99f --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequest.java @@ -0,0 +1,17 @@ +package io.shinhanlife.dap.biz.mcp.jsonrpc; + +import tools.jackson.databind.JsonNode; + +/** + * 검증을 통과한 JSON-RPC 2.0 요청의 불변 내부 표현입니다. {@link JsonRpcRequestParser}가 만들고 controller와 method handler가 사용하며, {@code id} 유무로 notification 여부를 판단합니다. HTTP 헤더나 인증 + * 정보는 포함하지 않고 request context가 별도로 관리합니다. + */ +public record JsonRpcRequest(String method, JsonNode params, JsonNode id) { + + /** + * id가 없는 요청인지 확인하여 JSON-RPC notification 여부를 판단합니다. + */ + public boolean notification() { + return id == null || id.isNull(); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParser.java b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParser.java new file mode 100644 index 0000000..67706c1 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParser.java @@ -0,0 +1,65 @@ +package io.shinhanlife.dap.biz.mcp.jsonrpc; + +import io.modelcontextprotocol.spec.McpSchema; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.JsonNodeFactory; + +/** + * HTTP 본문에서 역직렬화된 JSON을 서버 내부의 {@link JsonRpcRequest}로 바꾸는 JSON-RPC parser입니다. 설정된 MCP POST endpoint의 모든 요청이 이 클래스를 지나며 여기서 JSON 구조를 검사합니다. HTTP 경계 로그는 filter가 + * 담당하므로 이 parser는 별도 trace logger를 사용하지 않습니다. + */ +@Component +public class JsonRpcRequestParser { + + /** + * HTTP 본문에서 읽은 JSON 객체를 서버 내부의 {@link JsonRpcRequest}로 변환합니다. envelope를 먼저 검증하며 params가 없으면 비어 있는 JSON 객체를 사용합니다. 지원 method 여부는 handler registry가 확인합니다. + */ + public JsonRpcRequest parse(JsonNode envelope) { + try { + validate(envelope); + String method = envelope.get("method").asString(); + JsonNode params = + envelope.hasNonNull("params") + ? envelope.get("params") + : JsonNodeFactory.instance.objectNode(); + return new JsonRpcRequest(method, params, envelope.get("id")); + } catch (JsonRpcException exception) { + JsonNode id = envelope != null && envelope.isObject() ? envelope.get("id") : null; + throw new JsonRpcException(exception.errorCode(), exception.errorData(), id, exception); + } + } + + /** + * JSON-RPC 2.0 요청 envelope의 필수 구조와 타입을 검사합니다. + */ + private void validate(JsonNode envelope) { + if (envelope == null || !envelope.isObject()) { + throw new JsonRpcException( + JsonRpcErrorCode.INVALID_REQUEST, "JSON-RPC envelope must be an object"); + } + if (!McpSchema.JSONRPC_VERSION.equals(envelope.path("jsonrpc").asString(null))) { + throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "jsonrpc must be exactly '2.0'"); + } + String method = envelope.path("method").asString(null); + if (!StringUtils.hasText(method)) { + throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "method is required"); + } + boolean notification = method.startsWith("notifications/"); + if (!notification && (!envelope.has("id") || envelope.get("id").isNull())) { + throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "id is required for requests"); + } + if (envelope.has("id") + && !envelope.get("id").isNull() + && !envelope.get("id").isString() + && !envelope.get("id").isNumber()) { + throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "id must be a string or number"); + } + if (envelope.has("params") + && !envelope.get("params").isNull() + && !envelope.get("params").isObject()) { + throw new JsonRpcException(JsonRpcErrorCode.INVALID_PARAMS, "params must be an object"); + } + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcResponse.java b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcResponse.java new file mode 100644 index 0000000..7a01a9a --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcResponse.java @@ -0,0 +1,57 @@ +package io.shinhanlife.dap.biz.mcp.jsonrpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.modelcontextprotocol.spec.McpSchema; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder; + +import java.util.LinkedHashMap; +import java.util.Map; + +import tools.jackson.databind.JsonNode; + +/** + * MCP method handler의 성공 result 또는 JSON-RPC 표준 error를 담는 불변 응답 envelope입니다. {@code McpController}와 {@code McpExceptionHandler}가 설정된 MCP endpoint의 응답 본문으로 사용하며, 성공과 + * 오류를 동시에 넣지 않습니다. 주요 의존성은 request ID correlation을 위한 {@link JsonNode}와 null 필드를 제외하는 Jackson 직렬화 설정입니다. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record JsonRpcResponse(String jsonrpc, Object result, Error error, JsonNode id) { + + /** + * 정상 처리 결과와 원 요청 ID를 JSON-RPC 2.0 성공 응답으로 감쌉니다. + */ + public static JsonRpcResponse success(JsonNode id, Object result) { + return new JsonRpcResponse(McpSchema.JSONRPC_VERSION, result, null, id); + } + + /** + * 표준 오류 정보와 원 요청 ID를 JSON-RPC 2.0 실패 응답으로 감쌉니다. + */ + public static JsonRpcResponse failure(JsonNode id, Error error) { + return new JsonRpcResponse(McpSchema.JSONRPC_VERSION, null, error, id); + } + + /** + * 내부 오류 코드와 상세 정보를 guid가 포함된 JSON-RPC 실패 응답으로 변환합니다. + */ + public static JsonRpcResponse failure(JsonNode id, JsonRpcErrorCode code, Object details) { + Map data = new LinkedHashMap<>(); + McpRequestContextHolder.get() + .map(context -> context.guid()) + .ifPresent(guid -> data.put("guid", guid)); + if (details != null) { + data.put("details", details); + } + String message = + code == JsonRpcErrorCode.INVALID_PARAMS && details != null + ? code.message() + ": " + details + : code.message(); + return failure(id, new Error(code.code(), message, data)); + } + + /** + * JSON-RPC 오류의 code·message·선택 data를 담는 하위 값 객체입니다. {@link JsonRpcResponse#failure(JsonNode, JsonRpcErrorCode, Object)}가 생성하며, Tool 업무 실패 결과에는 사용하지 않습니다. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Error(int code, String message, Object data) { + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandler.java b/src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandler.java new file mode 100644 index 0000000..9d47530 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandler.java @@ -0,0 +1,51 @@ +package io.shinhanlife.dap.biz.mcp.method; + +import io.modelcontextprotocol.spec.McpSchema; +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse; +import org.springframework.stereotype.Component; + +/** + * MCP lifecycle의 {@code initialize} 요청을 처리해 서버 정보, capability, 선택 protocol version을 응답합니다. 설정된 MCP POST endpoint에서 {@code McpController}가 method별로 이 handler를 선택하며, + * 새 mcp-session-id 헤더 발급은 HTTP transport의 책임입니다. 주요 의존성은 서버명·버전·프로토콜 설정을 제공하는 {@link McpProperties}, MCP SDK 표준 초기화 모델, JSON-RPC 응답 envelope입니다. + */ +@Component +public class InitializeHandler implements McpMethodHandlerRegistry.Handler { + + private final McpProperties properties; + + /** + * initialize 응답에 사용할 서버 정보와 protocol 설정을 주입받습니다. + */ + public InitializeHandler(McpProperties properties) { + this.properties = properties; + } + + /** + * 이 handler가 담당하는 MCP method 이름인 `initialize`를 반환합니다. + */ + @Override + public String method() { + return McpSchema.METHOD_INITIALIZE; + } + + /** + * Agent Builder에 protocol version, 서버 정보, 지원 capability를 알려 주는 initialize 결과를 만듭니다. 요청 ID를 그대로 응답에 넣어 JSON-RPC correlation을 유지합니다. + */ + @Override + public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) { + McpSchema.Implementation serverInfo = + McpSchema.Implementation.builder(properties.server().name(), properties.server().version()) + .title(properties.server().title()) + .build(); + McpSchema.ServerCapabilities capabilities = + McpSchema.ServerCapabilities.builder().tools(false).build(); + McpSchema.InitializeResult result = + McpSchema.InitializeResult.builder( + properties.protocol().preferredVersion(), capabilities, serverInfo) + .build(); + return JsonRpcResponse.success(request.id(), result); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandler.java b/src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandler.java new file mode 100644 index 0000000..a02619d --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandler.java @@ -0,0 +1,34 @@ +package io.shinhanlife.dap.biz.mcp.method; + +import io.modelcontextprotocol.spec.McpSchema; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +/** + * AgentBuilder가 initialize 완료 뒤 보내는 {@code notifications/initialized} 알림을 수신하는 stateless handler입니다. 이 요청은 서버 상태나 세션을 만들지 않고 {@code McpController}가 HTTP 202으로 + * 마무리합니다. 별도 협력 객체 없이 표준 notification acknowledgement만 반환합니다. + */ +@Component +public class InitializedNotificationHandler implements McpMethodHandlerRegistry.Handler { + + /** + * 이 handler가 담당하는 `notifications/initialized` method 이름을 반환합니다. + */ + @Override + public String method() { + return McpSchema.METHOD_NOTIFICATION_INITIALIZED; + } + + /** + * Agent Builder의 initialize 완료 notification을 수용합니다. 서버 상태를 생성하지 않으며 {@code McpController}가 HTTP 202 빈 응답으로 최종 처리합니다. + */ + @Override + public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) { + return JsonRpcResponse.success(null, Map.of()); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/method/McpMethodHandlerRegistry.java b/src/main/java/io/shinhanlife/dap/biz/mcp/method/McpMethodHandlerRegistry.java new file mode 100644 index 0000000..5e30d41 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/method/McpMethodHandlerRegistry.java @@ -0,0 +1,65 @@ +package io.shinhanlife.dap.biz.mcp.method; + +import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; + +/** + * Spring이 만든 MCP method handler를 method 문자열 기준으로 인덱싱하고, {@code McpController}의 명시적 dispatch를 지원합니다. 설정된 MCP endpoint 요청은 transport 검증 후 이 registry에서 + * initialize·tools/list·tools/call handler를 찾아 처리합니다. 주요 의존성은 {@link Handler} 구현체 목록과 지원하지 않는 method를 거절하는 JSON-RPC 오류 모델입니다. + */ +@Component +public class McpMethodHandlerRegistry { + + private final Map handlers; + + /** + * Spring이 찾은 모든 handler를 method 이름 기준의 읽기 전용 map으로 구성합니다. 같은 method를 담당하는 handler가 둘이면 시작 시 즉시 실패해 모호한 dispatch를 막습니다. + */ + public McpMethodHandlerRegistry(List handlers) { + Map indexed = new HashMap<>(); + handlers.forEach( + handler -> { + if (indexed.putIfAbsent(handler.method(), handler) != null) { + throw new IllegalStateException("Duplicate MCP method handler: " + handler.method()); + } + }); + this.handlers = Map.copyOf(indexed); + } + + /** + * 요청 method에 맞는 handler를 반환하고, 지원하지 않으면 Method not found 오류를 발생시킵니다. + */ + public Handler resolve(String method) { + Handler handler = handlers.get(method); + if (handler == null) { + throw new JsonRpcException( + JsonRpcErrorCode.METHOD_NOT_FOUND, "Unsupported MCP method: " + method); + } + return handler; + } + + /** + * MCP method별 처리기를 위한 내부 확장 계약입니다. {@code McpController}는 이 계약만 의존하므로 새 method는 이 인터페이스 구현체를 Bean으로 추가해 등록할 수 있습니다. + */ + public interface Handler { + + /** + * 이 handler가 처리할 JSON-RPC method 문자열을 반환합니다. + */ + String method(); + + /** + * 검증된 요청과 request context를 받아 method별 JSON-RPC 응답을 생성합니다. + */ + JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandler.java b/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandler.java new file mode 100644 index 0000000..ed41129 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandler.java @@ -0,0 +1,120 @@ +package io.shinhanlife.dap.biz.mcp.method; + +import io.modelcontextprotocol.spec.McpSchema; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; +import io.shinhanlife.dap.biz.mcp.execute.ToolCall; +import io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import tools.jackson.databind.JsonNode; + +/** + * MCP {@code tools/call} 요청을 받아 Tool 실행 계층으로 전달하고 MCP result 형식으로 되돌리는 method handler입니다. {@link ToolExecutionService}를 통해 Tool을 실행하고 결과는 MCP SDK의 + * {@link McpSchema.CallToolResult}로 만듭니다. 주요 의존성은 실행 서비스이며, 잘못된 요청은 최상위 JSON-RPC error로, Tool 자체 실패는 {@code result.isError=true}로 구분합니다. + */ +@Component +public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { + + private final ToolExecutionService executionService; + + /** + * Tool 실행 서비스를 주입받습니다. + */ + public ToolsCallHandler(ToolExecutionService executionService) { + this.executionService = executionService; + } + + /** + * 이 handler가 담당하는 `tools/call` method 이름을 반환합니다. + */ + @Override + public String method() { + return McpSchema.METHOD_TOOLS_CALL; + } + + /** + * 일반 tools/call 요청에서 명시된 단일 Tool을 실행합니다. Tool 실행 계열 오류는 MCP 규칙에 맞춰 최상위 error가 아닌 `isError=true` result로 변환합니다. + */ + @Override + public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) { + ToolCall call = extract(request); + try { + ToolExecutionService.Result result = executionService.execute(call, context); + return JsonRpcResponse.success(request.id(), successResult(result)); + } catch (JsonRpcException exception) { + if (isToolExecutionFailure(exception.errorCode())) { + return JsonRpcResponse.success(request.id(), failureResult(exception.errorData())); + } + throw exception; + } + } + + /** + * 표준 tools/call params에서 Tool 이름과 object arguments를 검증해 내부 호출 값으로 만듭니다. + */ + private ToolCall extract(JsonRpcRequest request) { + String toolName = request.params().path("name").asString(null); + JsonNode arguments = request.params().get("arguments"); + if (!StringUtils.hasText(toolName)) { + throw invalid(request, "params.name is required"); + } + if (arguments == null || !arguments.isObject()) { + throw invalid(request, "params.arguments must be an object"); + } + return new ToolCall(toolName, arguments); + } + + /** + * 요청 ID를 보존한 Invalid params 예외를 만듭니다. + */ + private JsonRpcException invalid(JsonRpcRequest request, String details) { + return new JsonRpcException(JsonRpcErrorCode.INVALID_PARAMS, details, request.id(), null); + } + + /** + * Tool 실행 결과를 text content와 실행 시간 meta를 가진 MCP 성공 결과로 변환합니다. + */ + private McpSchema.CallToolResult successResult(ToolExecutionService.Result result) { + McpSchema.TextContent content = + McpSchema.TextContent.builder(asText(result.data())) + .meta(Map.of("searchTime", result.durationMillis())) + .build(); + return McpSchema.CallToolResult.builder(List.of(content)).isError(false).build(); + } + + /** + * Tool 응답을 MCP text content에 넣을 문자열로 바꾸며 JSON 객체와 배열은 compact JSON을 유지합니다. + */ + private String asText(JsonNode data) { + if (data == null || data.isNull()) { + return ""; + } + return data.isString() ? data.asString() : data.toString(); + } + + /** + * Tool 실패 상세를 사용자에게 전달 가능한 text content와 `isError=true` 결과로 변환합니다. + */ + private McpSchema.CallToolResult failureResult(Object details) { + McpSchema.TextContent content = McpSchema.TextContent.builder(String.valueOf(details)).build(); + return McpSchema.CallToolResult.builder(List.of(content)).isError(true).build(); + } + + /** + * JSON-RPC envelope 오류가 아니라 MCP Tool result로 표현해야 하는 실행 계열 오류인지 구분합니다. + */ + private boolean isToolExecutionFailure(JsonRpcErrorCode errorCode) { + return errorCode == JsonRpcErrorCode.TOOL_EXECUTION_ERROR + || errorCode == JsonRpcErrorCode.TOOL_TIMEOUT + || errorCode == JsonRpcErrorCode.UNAUTHORIZED + || errorCode == JsonRpcErrorCode.FORBIDDEN; + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandler.java b/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandler.java new file mode 100644 index 0000000..5b79e2d --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandler.java @@ -0,0 +1,89 @@ +package io.shinhanlife.dap.biz.mcp.method; + +import io.modelcontextprotocol.spec.McpSchema; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse; +import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; +import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService; + +import java.util.List; + +import org.springframework.stereotype.Component; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * MCP {@code tools/list} 요청에 대해 AgentBuilder에 공개할 도구 목록을 만드는 method handler입니다. 내부 Tool Registry의 활성 metadata를 읽어 MCP SDK의 표준 {@link McpSchema.Tool}과 + * {@link McpSchema.ListToolsResult}로 변환합니다. 주요 의존성은 캐시 및 원천 조회를 감싸는 {@link io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService}이며, Jackson mapper는 local + * catalog의 공개 필드만 SDK 모델로 옮깁니다. endpoint·timeout 등 실행용 운영 정보는 응답에 노출하지 않습니다. + */ +@Component +public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { + + private final ToolRegistryService registryService; + private final ObjectMapper objectMapper; + + /** + * 활성 Tool metadata를 조회할 Registry service와 SDK 모델 변환용 Jackson mapper를 주입받습니다. + */ + public ToolsListHandler(ToolRegistryService registryService, ObjectMapper objectMapper) { + this.registryService = registryService; + this.objectMapper = objectMapper; + } + + /** + * 이 handler가 담당하는 `tools/list` method 이름을 반환합니다. + */ + @Override + public String method() { + return McpSchema.METHOD_TOOLS_LIST; + } + + /** + * 실행용 metadata에서 외부 공개 필드만 골라 MCP tools/list 응답을 만듭니다. 내부 endpoint나 timeout 정보는 Agent Builder 응답에 노출하지 않습니다. + */ + @Override + public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) { + List tools = registryService.listTools().stream().map(this::toMcpTool).toList(); + return JsonRpcResponse.success(request.id(), McpSchema.ListToolsResult.builder(tools).build()); + } + + /** + * 원천이 보존한 공개 Tool 정의가 있으면 SDK Tool 모델로 변환하고, 없으면 기본 공개 필드를 조립합니다. 변환 직전에 {@code _meta}를 한 번 더 제거합니다. 두 원천이 이미 제거해서 넘기지만, {@link McpSchema.Tool}은 + * {@code _meta}를 담을 수 있는 표준 필드를 가지고 있어 그대로 통과시키면 endpoint·timeout이 Agent Builder 응답에 그대로 실린다. 공개 경계 바로 앞의 마지막 방어선이다. + */ + private McpSchema.Tool toMcpTool(ToolMetadata metadata) { + if (metadata.publicDefinition() != null) { + return objectMapper.convertValue( + withoutExecutionMetadata(metadata.publicDefinition()), McpSchema.Tool.class); + } + return McpSchema.Tool.builder(metadata.name(), toInputSchema(metadata)) + .description(metadata.description()) + .build(); + } + + /** + * 공개 Tool 정의를 복사해 실행용 {@code _meta}만 제거합니다. 원본 snapshot은 바꾸지 않습니다. + */ + private JsonNode withoutExecutionMetadata(JsonNode definition) { + if (!definition.isObject() || !definition.has("_meta")) { + return definition; + } + ObjectNode copy = ((ObjectNode) definition).deepCopy(); + copy.remove("_meta"); + return copy; + } + + /** + * 기존 Registry 응답에 inputSchema가 없으면 SDK 필수 조건을 만족하는 빈 object schema로 정규화합니다. schema가 있으면 field를 변경하지 않고 Jackson Map으로 옮깁니다. + */ + @SuppressWarnings("unchecked") + private java.util.Map toInputSchema(ToolMetadata metadata) { + if (metadata.inputSchema() == null || metadata.inputSchema().isNull()) { + return java.util.Map.of("type", "object", "properties", java.util.Map.of()); + } + return objectMapper.convertValue(metadata.inputSchema(), java.util.Map.class); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolBundleStatusEndpoint.java b/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolBundleStatusEndpoint.java new file mode 100644 index 0000000..9fcfbfc --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolBundleStatusEndpoint.java @@ -0,0 +1,41 @@ +package io.shinhanlife.dap.biz.mcp.observability; + +import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery; +import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus; + +import java.util.List; +import java.util.Map; + +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +/** + * 설정에 선언된 Tool Service bundle의 조회 상태를 management endpoint로 제공하는 운영 진단 구성요소입니다. MCP JSON-RPC 요청을 처리하지 않으며 Actuator가 {@code toolBundles} read operation을 호출합니다. + * 주요 의존성은 bundle별 last-good과 실패 상태를 보관하는 {@link ToolBundleDiscovery}이며, manifest URL이나 Tool schema 같은 내부 상세 정보는 응답에 포함하지 않습니다. + */ +@Component +@Endpoint(id = "toolBundles") +@Profile("!local") +@ConditionalOnProperty(prefix = "mcp.discovery", name = "enabled", havingValue = "true") +public class ToolBundleStatusEndpoint { + + private final ToolBundleDiscovery discovery; + + /** + * 운영 조회 시 사용할 bundle discovery 상태 저장소를 주입받습니다. + */ + public ToolBundleStatusEndpoint(ToolBundleDiscovery discovery) { + this.discovery = discovery; + } + + /** + * 선언된 모든 bundle의 현재 상태를 읽기 전용 Map으로 반환합니다. 상태 조회는 manifest refresh나 Tool 실행을 유발하지 않습니다. + */ + @ReadOperation + public Map> bundleStatuses() { + return Map.of("bundles", discovery.statuses()); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java b/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java new file mode 100644 index 0000000..59faeb8 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java @@ -0,0 +1,43 @@ +package io.shinhanlife.dap.biz.mcp.observability; + +import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryRefreshScheduler; +import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.stereotype.Component; + +/** + * Tool discovery 첫 시도와 in-memory snapshot 적재 여부로 readiness를 판정하는 health indicator입니다. MCP 요청을 직접 처리하지 않으며 {@code /actuator/health/readiness}의 readiness group에서 + * 평가됩니다. 원천 조회가 실패해도 memory 또는 Redis의 last-good snapshot이 있으면 서비스 가능 상태로 인정하지만, 사용할 snapshot이 전혀 없는 Pod은 트래픽을 받지 않습니다. 주요 협력 객체는 기동 조회 완료 시점을 제공하는 + * {@link ToolRegistryRefreshScheduler}와 요청 경로의 snapshot을 소유하는 {@link ToolRegistryService}입니다. + */ +@Component +public class ToolCatalogHealthIndicator implements HealthIndicator { + + private final ToolRegistryRefreshScheduler scheduler; + private final ToolRegistryService registryService; + + /** + * 기동 preload 시점과 usable Tool snapshot을 함께 확인할 협력 객체를 주입받습니다. + */ + public ToolCatalogHealthIndicator( + ToolRegistryRefreshScheduler scheduler, ToolRegistryService registryService) { + this.scheduler = scheduler; + this.registryService = registryService; + } + + /** + * 기동 preload 시도가 끝났고 usable snapshot이 있을 때만 UP을 반환합니다. 조회 상태 외에 Tool 이름이나 개수 같은 카탈로그 내용은 노출하지 않습니다. + */ + @Override + public Health health() { + boolean firstAttemptCompleted = scheduler.firstAttemptCompleted(); + boolean usableSnapshot = registryService.hasUsableSnapshot(); + Health.Builder health = firstAttemptCompleted && usableSnapshot ? Health.up() : Health.down(); + return health.withDetail( + "firstDiscoveryAttempt", + firstAttemptCompleted ? "completed" : "pending") + .withDetail("usableSnapshot", usableSnapshot) + .build(); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/observability/TraceLogger.java b/src/main/java/io/shinhanlife/dap/biz/mcp/observability/TraceLogger.java new file mode 100644 index 0000000..f7af42f --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/observability/TraceLogger.java @@ -0,0 +1,95 @@ +package io.shinhanlife.dap.biz.mcp.observability; + +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder; + +import java.util.StringJoiner; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * MCP HTTP 입출구와 Tool HTTP 호출 경계의 이벤트를 한 줄 key=value 로그로 남깁니다. 현재 요청의 guid와 requestId는 {@link McpRequestContextHolder}에서 읽어 로그 메시지에 직접 포함하므로 MDC를 사용하지 않습니다. + * payload, credential, 사원 식별자({@code employeeNo}·{@code virtualEmployeeNo})는 기록하지 않습니다. 주요 의존성은 로그 활성화 정책을 제공하는 {@link McpProperties}와 SLF4J입니다. + */ +@Component +public class TraceLogger { + + private static final Logger log = LoggerFactory.getLogger(TraceLogger.class); + private final McpProperties properties; + + /** + * trace 로그 활성화 여부를 판단할 설정 객체를 주입받습니다. + */ + public TraceLogger(McpProperties properties) { + this.properties = properties; + } + + /** + * 정상적인 처리 단계를 key=value 형식의 구조화 로그로 남깁니다. trace 로그 설정이 켜진 경우에만 기록합니다. + */ + public void event(String event, Object... keyValues) { + if (properties.trace().enabled()) { + McpRequestContext context = McpRequestContextHolder.get().orElse(null); + log.info( + "event={} guid={} requestId={} {}", + safe(event), + guid(context), + requestId(context), + fields(keyValues)); + } + } + + /** + * 예외가 발생한 처리 단계를 오류 로그로 남깁니다. 오류 로그에는 예외 종류와 메시지를 함께 남겨 원인 분석을 돕습니다. + */ + public void error(String event, Throwable error, Object... keyValues) { + McpRequestContext context = McpRequestContextHolder.get().orElse(null); + log.error( + "event={} guid={} requestId={} {} errorType={} errorMessage={}", + safe(event), + guid(context), + requestId(context), + fields(keyValues), + error.getClass().getSimpleName(), + safe(error.getMessage()), + error); + } + + /** + * 가변 인자로 받은 키와 값을 두 개씩 묶어 읽기 쉬운 key=value 문자열로 바꿉니다. 홀수 개가 들어오면 짝이 없는 마지막 값은 기록하지 않습니다. + */ + private String fields(Object... keyValues) { + StringJoiner joiner = new StringJoiner(" "); + for (int index = 0; index + 1 < keyValues.length; index += 2) { + joiner.add(safe(keyValues[index]) + "=" + safe(keyValues[index + 1])); + } + return joiner.toString(); + } + + /** + * 줄바꿈과 공백을 치환해 한 로그 이벤트가 여러 줄로 갈라지지 않도록 문자열을 정리합니다. + */ + private String safe(Object value) { + if (value == null) { + return ""; + } + return String.valueOf(value).replace('\n', '_').replace('\r', '_').replace(' ', '_'); + } + + /** + * 요청 context가 있을 때 end-to-end 상관 값 guid를 반환하고, background 로그에는 빈 값을 사용합니다. + */ + private String guid(McpRequestContext context) { + return context == null ? "" : safe(context.guid()); + } + + /** + * 요청 context가 있을 때 개별 HTTP request ID를 반환하고, background 로그에는 빈 값을 사용합니다. + */ + private String requestId(McpRequestContext context) { + return context == null ? "" : safe(context.requestId()); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClient.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClient.java new file mode 100644 index 0000000..4acd178 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClient.java @@ -0,0 +1,135 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * 매니페스트 조회를 끈 local profile에서 Agent Builder {@code tools/list} 응답 형식의 JSON 파일을 실행용 {@link ToolMetadata}로 변환하는 adapter입니다. {@code tools/list}와 local + * {@code tools/call}이 metadata를 필요로 할 때 {@link ToolRegistryService}가 cache miss 후 호출합니다. 주요 의존성은 설정의 local 파일 경로를 제공하는 McpProperties와 JSON 파싱용 ObjectMapper이며, + * 운영 HTTP Registry를 호출하지 않습니다. + */ +@Component +@Profile("local") +@ConditionalOnProperty( + prefix = "mcp.discovery", + name = "enabled", + havingValue = "false", + matchIfMissing = true) +public class LocalFileToolRegistryClient implements ToolRegistryClient { + + private final ResourceLoader resourceLoader; + private final ObjectMapper objectMapper; + private final McpProperties properties; + + /** + * local Tool catalog의 resource loader, JSON mapper, 파일 위치 설정을 주입받습니다. + */ + public LocalFileToolRegistryClient( + ResourceLoader resourceLoader, ObjectMapper objectMapper, McpProperties properties) { + this.resourceLoader = resourceLoader; + this.objectMapper = objectMapper; + this.properties = properties; + } + + /** + * local profile에서 설정된 JSON 파일의 {@code result.tools[]}를 읽어 실행 metadata 목록으로 변환합니다. 파일이 없거나 읽을 수 없거나 내용이 비어 있으면 Registry unavailable 오류로 변환합니다. + */ + @Override + public List fetchTools() { + String location = properties.registry().localToolFile(); + Resource resource = resourceLoader.getResource(location); + try (var inputStream = resource.getInputStream()) { + JsonNode document = objectMapper.readTree(inputStream); + return toToolMetadataList(document, location); + } catch (JsonRpcException exception) { + throw exception; + } catch (IOException exception) { + throw unavailable(location, "Unable to read local Tool catalog", exception); + } + } + + /** + * Agent Builder tools/list 응답 또는 Tool Service manifest의 공개 정의와 {@code _meta} 실행 정보를 내부 ToolMetadata로 조합합니다. 두 형식 모두 배열이 없으면 오류로 처리해 빈 목록을 조용히 반환하지 않습니다. + */ + private List toToolMetadataList(JsonNode document, String location) { + JsonNode tools = document.path("result").path("tools"); + if (!tools.isArray()) { + tools = document.path("tools"); + } + if (!tools.isArray()) { + throw unavailable(location, "Local Tool catalog must contain tools array", null); + } + List metadata = new ArrayList<>(); + for (JsonNode tool : tools) { + metadata.add(toToolMetadata(tool, location)); + } + return List.copyOf(metadata); + } + + /** + * 한 공개 Tool 정의에서 name·description·inputSchema와 {@code _meta}의 endpoint·timeout·상태를 추출합니다. 실행에 필수인 name 또는 endpoint가 없으면 local 설정 오류로 처리해 잘못된 Tool 호출을 + * 막습니다. + */ + private ToolMetadata toToolMetadata(JsonNode tool, String location) { + JsonNode meta = tool.path("_meta"); + String name = requiredText(tool, "name", location); + String endpoint = requiredText(meta, "endpoint", location); + String version = meta.path("version").asString("local"); + int timeoutMillis = + meta.path("timeoutMillis").asInt(properties.toolClient().readTimeoutMillis()); + boolean enabled = meta.path("enabled").asBoolean(true); + return new ToolMetadata( + name, + version, + tool.path("description").asString(""), + endpoint, + tool.get("inputSchema"), + timeoutMillis, + enabled, + publicDefinition(tool)); + } + + /** + * local 파일의 공개 Tool 정의를 복사하고 내부 실행 metadata인 {@code _meta}만 제거합니다. + */ + private JsonNode publicDefinition(JsonNode tool) { + ObjectNode definition = ((ObjectNode) tool).deepCopy(); + definition.remove("_meta"); + return definition; + } + + /** + * local sample의 필수 문자열 field를 검증하고 누락 시 Registry unavailable 오류로 바꿉니다. + */ + private String requiredText(JsonNode source, String fieldName, String location) { + String value = source.path(fieldName).asString(null); + if (value == null || value.isBlank()) { + throw unavailable(location, "Local Tool catalog is missing " + fieldName, null); + } + return value; + } + + /** + * 파일 위치와 실패 이유를 포함하되 원문 payload는 노출하지 않는 Registry 오류를 만듭니다. + */ + private JsonRpcException unavailable(String location, String message, Exception cause) { + String details = message + ": " + location; + return cause == null + ? new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, details) + : new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, details, cause); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCache.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCache.java new file mode 100644 index 0000000..847cab9 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCache.java @@ -0,0 +1,92 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import io.shinhanlife.dap.biz.mcp.config.McpProperties; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +/** + * MCP replica 사이에서 Tool snapshot을 공유하는 선택적 Redis cache adapter입니다. 원천이 아니라 공유 지점이므로 조회 성공 결과만 저장하고, 읽기·쓰기·직렬화 실패는 모두 cache miss로 처리합니다. + * {@code tools/list} 요청 경로에서는 호출하지 않으며 {@link ToolRegistryService}의 배경 갱신과 warm start에서만 사용합니다. 주요 의존성은 RedisTemplate, ObjectMapper와 {@link McpProperties}입니다. + */ +@Component +@ConditionalOnProperty(prefix = "mcp.redis", name = "enabled", havingValue = "true") +public class RedisToolRegistryCache { + + /** + * 캐시에 저장하는 JSON 구조의 버전입니다. 구조가 바뀌면 이 값을 올려 서로 다른 버전의 MCP가 같은 key를 읽어 오염되는 것을 막습니다. + */ + static final String CACHE_SCHEMA_VERSION = "v1"; + + private static final Logger logger = LoggerFactory.getLogger(RedisToolRegistryCache.class); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final String cacheKey; + private final Duration ttl; + + /** + * Redis 접근, JSON 변환, key와 TTL 설정을 주입받아 공유 cache를 구성합니다. + */ + public RedisToolRegistryCache( + StringRedisTemplate redisTemplate, ObjectMapper objectMapper, McpProperties properties) { + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + this.cacheKey = + "%s:%s:%s:all" + .formatted(properties.redis().keyPrefix(), properties.identity(), CACHE_SCHEMA_VERSION); + this.ttl = Duration.ofSeconds(Math.max(30, properties.registry().refreshIntervalSeconds() * 3)); + } + + /** + * 이 MCP 인스턴스가 사용하는 Redis key를 반환합니다. 운영 진단과 테스트에서 key 규칙을 확인할 때 사용합니다. + */ + public String key() { + return cacheKey; + } + + /** + * 다른 replica가 저장한 Tool snapshot을 읽습니다. key miss, Redis 장애와 역직렬화 오류를 모두 빈 Optional로 처리해 호출자가 자기 결과로 진행하게 합니다. + */ + public Optional> loadSnapshot() { + try { + String json = redisTemplate.opsForValue().get(cacheKey); + if (json == null) { + return Optional.empty(); + } + return Optional.of(objectMapper.readValue(json, new TypeReference<>() { + })); + } catch (Exception exception) { + logFailure("read", exception); + return Optional.empty(); + } + } + + /** + * 원천 조회에 성공한 snapshot만 공유 지점에 저장하고 TTL을 설정합니다. 실패한 조회 결과를 저장하면 다른 replica가 구해 온 정상 snapshot을 덮어쓰므로 호출자가 성공 시에만 호출해야 합니다. 저장 실패는 로그만 남기며 MCP 응답이나 배경 갱신을 + * 실패시키지 않습니다. + */ + public void saveSnapshot(List tools) { + try { + redisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(tools), ttl); + } catch (Exception exception) { + logFailure("write", exception); + } + } + + /** + * payload와 credential을 남기지 않고 Redis 실패 작업과 예외 타입만 기록합니다. + */ + private void logFailure(String operation, Exception exception) { + logger.warn("Redis Tool cache {} failed: {}", operation, exception.getClass().getSimpleName()); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscovery.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscovery.java new file mode 100644 index 0000000..fd4d6f3 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscovery.java @@ -0,0 +1,428 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.config.McpProperties.Bundle; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * 설정에 선언된 Tool Service bundle의 매니페스트를 동시에 조회·검증하고 bundle별 상태를 보관하는 discovery 구성요소입니다. MCP 요청을 직접 처리하지 않으며 {@link ToolBundleRegistryClient}의 배경 갱신에서만 호출됩니다. 개별 + * bundle의 실패는 예외가 아니라 결과값으로 반환해, 한 bundle의 장애가 나머지 bundle의 성공분까지 버리지 않게 합니다. 최초 원격 조회가 실패한 경우에만 설정된 local manifest를 cold-start fallback으로 사용합니다. 주요 의존성은 bundle + * 전용 RestClient, JSON mapper, ResourceLoader, {@link McpProperties}의 bundle·discovery 설정입니다. + */ +@Component +@ConditionalOnProperty(prefix = "mcp.discovery", name = "enabled", havingValue = "true") +public class ToolBundleDiscovery { + + private static final Logger logger = LoggerFactory.getLogger(ToolBundleDiscovery.class); + private static final Pattern TOOL_NAME = Pattern.compile("[A-Za-z0-9_./-]{1,64}"); + + private final RestClient restClient; + private final ObjectMapper objectMapper; + private final McpProperties properties; + private final ResourceLoader resourceLoader; + private final Map states = new ConcurrentHashMap<>(); + + /** + * bundle 매니페스트 조회용 RestClient, JSON mapper, 조회 정책 설정을 주입받습니다. local fallback 파일은 Spring ResourceLoader로 읽어 file:과 classpath: 위치를 모두 지원합니다. + */ + public ToolBundleDiscovery( + @Qualifier("manifestRestClient") RestClient restClient, + ObjectMapper objectMapper, + McpProperties properties) { + this.restClient = restClient; + this.objectMapper = objectMapper; + this.properties = properties; + this.resourceLoader = new DefaultResourceLoader(); + } + + /** + * 활성 bundle 전체를 동시에 조회해 bundle별 결과를 반환합니다. 순차 조회는 소요 시간이 합산되어 기동과 갱신을 지연시키므로 virtual thread로 병렬 조회하며, 각 작업이 자기 예외를 결과값으로 변환하므로 이 method는 예외를 던지지 않습니다. + */ + public List discoverAll() { + List targets = properties.enabledBundles(); + if (targets.isEmpty()) { + return List.of(); + } + List> futures; + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + futures = + targets.stream() + .map(bundle -> executor.submit(() -> discoverOne(bundle))) + .toList(); + } + // close()가 모든 작업의 종료를 기다린 뒤이므로 이 시점의 Future는 모두 완료 상태다. + List results = new ArrayList<>(); + for (int index = 0; index < futures.size(); index++) { + results.add(resultOrEmpty(futures.get(index), targets.get(index))); + } + return List.copyOf(results); + } + + /** + * 완료된 조회 작업의 결과를 꺼내되, 작업 자체가 비정상 종료했으면 빈 결과로 대체합니다. {@link #discoverOne}이 이미 모든 RuntimeException을 값으로 바꾸므로 이 경로는 예상 밖의 오류에 대한 마지막 방어선입니다. + */ + private BundleResult resultOrEmpty(Future future, Bundle bundle) { + try { + return future.resultNow(); + } catch (RuntimeException exception) { + logger.warn( + "Tool bundle discovery task ended abnormally: bundleId={}, reason={}", + bundle.id(), + exception.getClass().getSimpleName()); + return new BundleResult(bundle.id(), false, List.of()); + } + } + + /** + * bundle 하나의 매니페스트를 조회·검증하고 그 결과로 bundle 상태를 갱신합니다. 성공하면 새 Tool 목록을 채택하고 실패 횟수를 초기화하며, 실패하면 직전 성공본을 유지한 채 실패 횟수만 올립니다. 통신 실패만으로 Tool을 제거하지 않으며 정상 + * manifest에서 제거가 확인될 때만 새 목록을 채택합니다. + */ + BundleResult discoverOne(Bundle bundle) { + BundleState state = states.computeIfAbsent(bundle.id(), id -> new BundleState()); + try { + Manifest manifest = fetchAndValidate(bundle); + return state.recordSuccess(bundle.id(), manifest.tools(), manifest.revision()); + } catch (RuntimeException exception) { + BundleResult fallback = loadColdStartFallback(bundle, state, exception); + if (fallback != null) { + return fallback; + } + // 실패를 값으로 돌려주는 지점이다. 여기서 예외를 올리면 다른 bundle의 성공분까지 함께 버려진다. + logger.warn( + "Tool bundle discovery failed: bundleId={}, reason={}", + bundle.id(), + exception.getClass().getSimpleName()); + return state.recordFailure(bundle.id(), exception.getClass().getSimpleName()); + } + } + + /** + * 매니페스트를 HTTP로 읽어 크기 상한과 스키마 규칙을 검증한 뒤 실행 metadata 목록으로 변환합니다. 검증에 어긋나면 해당 Tool만 걸러내지 않고 bundle 전체를 거부합니다. 일부만 반영된 카탈로그는 잘못된 이름으로 조용히 실행되거나 필요한 Tool이 사라진 + * 상태를 만들어, 직전 성공본을 유지하는 것보다 나쁘기 때문입니다. + */ + private Manifest fetchAndValidate(Bundle bundle) { + return parseAndValidate(bundle, fetchManifestBody(bundle)); + } + + /** + * 원격 매니페스트를 한 번도 받지 못한 bundle의 설정된 local manifest를 읽어 검증합니다. 정상 원격 snapshot이 있으면 호출하지 않으므로 테스트 파일이 운영 목록을 덮어쓰지 않습니다. + */ + private BundleResult loadColdStartFallback( + Bundle bundle, BundleState state, RuntimeException remoteFailure) { + String location = bundle.fallbackManifestFile(); + if (state.hasSnapshot() || location == null || location.isBlank()) { + return null; + } + try { + Manifest manifest = parseAndValidate(bundle, fetchFallbackManifestBody(location)); + logger.warn( + "Tool bundle discovery used local fallback: bundleId={}, reason={}", + bundle.id(), + remoteFailure.getClass().getSimpleName()); + return state.recordFallback( + bundle.id(), manifest.tools(), manifest.revision(), remoteFailure.getClass().getSimpleName()); + } catch (RuntimeException fallbackFailure) { + return null; + } + } + + /** + * 원격 또는 local 원천에서 읽은 문자열을 동일한 bundle 규칙으로 검증합니다. 어느 원천이든 bundle ID·이름 접두사·schema 규칙이 다르면 전체 목록을 채택하지 않습니다. + */ + private Manifest parseAndValidate(Bundle bundle, String body) { + if (body == null || body.isBlank()) { + throw new IllegalStateException("empty manifest body"); + } + JsonNode manifest = objectMapper.readTree(body); + String declaredId = manifest.path("bundleId").asString(null); + if (!bundle.id().equals(declaredId)) { + throw new IllegalStateException("manifest bundleId does not match configuration"); + } + JsonNode tools = manifest.path("tools"); + if (!tools.isArray()) { + throw new IllegalStateException("manifest must contain a tools array"); + } + if (tools.size() > properties.discovery().maxToolsPerBundle()) { + throw new IllegalStateException("bundle exceeds maxToolsPerBundle"); + } + Set seenNames = new HashSet<>(); + List metadata = new ArrayList<>(); + for (JsonNode tool : tools) { + ToolMetadata converted = toToolMetadata(bundle, tool); + if (!seenNames.add(converted.name())) { + throw new IllegalStateException("duplicate Tool name in manifest"); + } + metadata.add(converted); + } + return new Manifest(manifest.path("revision").asString(null), List.copyOf(metadata)); + } + + /** + * 설정된 local fallback 파일을 크기 상한 안에서 읽습니다. 파일 경로나 본문은 로그에 남기지 않으며, 읽기 실패는 원격 실패를 가리는 대신 기존 unavailable 처리로 이어집니다. + */ + private String fetchFallbackManifestBody(String location) { + int maxBytes = properties.discovery().maxManifestBytes(); + Resource resource = resourceLoader.getResource(location); + try (InputStream input = resource.getInputStream()) { + byte[] bytes = input.readNBytes(maxBytes + 1); + if (bytes.length > maxBytes) { + throw new IllegalStateException("fallback manifest exceeds " + maxBytes + " bytes"); + } + return new String(bytes, StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalStateException("unable to read fallback manifest", exception); + } + } + + /** + * manifest 응답을 설정 상한보다 한 byte만 더 읽어 초과 여부를 확인합니다. 전체 응답을 먼저 문자열로 적재하지 않으므로 잘못된 대용량 응답이 MCP heap을 불필요하게 소비하지 않습니다. + */ + private String fetchManifestBody(Bundle bundle) { + int maxBytes = properties.discovery().maxManifestBytes(); + return restClient + .get() + .uri(bundle.manifestUrl()) + .exchange( + (request, response) -> { + if (response.getStatusCode().isError()) { + throw new IllegalStateException( + "manifest returned HTTP " + response.getStatusCode().value()); + } + try (InputStream input = response.getBody()) { + byte[] bytes = input.readNBytes(maxBytes + 1); + if (bytes.length > maxBytes) { + throw new IllegalStateException("manifest exceeds " + maxBytes + " bytes"); + } + return new String(bytes, StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalStateException("unable to read manifest response", exception); + } + }); + } + + /** + * 한 번의 조회에서 검증을 통과한 매니페스트 내용입니다. {@code revision}은 변경 감지와 운영 진단에만 쓰는 보조 값입니다. + */ + private record Manifest(String revision, List tools) { + } + + /** + * 매니페스트의 Tool 하나를 실행 metadata로 변환하며, 실행 주소는 설정의 {@code baseEndpoint}에서만 가져옵니다. 매니페스트가 endpoint 성격의 값을 담고 있어도 읽지 않으므로 Tool Service가 호출 대상을 바꿀 수 없습니다. 이름 + * 규칙·{@code namePrefix}·필수 필드를 위반하면 bundle 전체를 거부하도록 예외를 던집니다. + */ + private ToolMetadata toToolMetadata(Bundle bundle, JsonNode tool) { + String name = tool.path("name").asString(null); + if (name == null || !TOOL_NAME.matcher(name).matches()) { + throw new IllegalStateException("Tool name must match [A-Za-z0-9_./-]{1,64}"); + } + String prefix = bundle.namePrefix(); + if (prefix != null && !prefix.isBlank() && !name.startsWith(prefix)) { + throw new IllegalStateException("Tool name does not start with the bundle namePrefix"); + } + String description = tool.path("description").asString(null); + if (description == null || description.isBlank()) { + throw new IllegalStateException("Tool description is required"); + } + JsonNode inputSchema = tool.get("inputSchema"); + if (inputSchema == null || !inputSchema.isObject()) { + throw new IllegalStateException("Tool inputSchema must be a JSON Schema object"); + } + JsonNode meta = tool.path("_meta"); + String version = meta.path("version").asString(null); + if (version == null || version.isBlank()) { + throw new IllegalStateException("Tool _meta.version is required"); + } + return new ToolMetadata( + name, + version, + description, + bundle.baseEndpoint().replaceAll("/+$", ""), + inputSchema, + clampTimeout(meta), + meta.path("enabled").asBoolean(true), + publicDefinition(tool)); + } + + /** + * Tool이 선언한 timeout을 설정 상한으로 절삭해, 한 Tool이 요청 예산 전체를 소모하지 못하게 합니다. + */ + private int clampTimeout(JsonNode meta) { + int max = properties.discovery().maxToolTimeoutMillis(); + if (!meta.path("timeoutMillis").isNumber()) { + return Math.min(properties.toolClient().readTimeoutMillis(), max); + } + int declared = meta.path("timeoutMillis").intValue(); + return declared <= 0 + ? Math.min(properties.toolClient().readTimeoutMillis(), max) + : Math.min(declared, max); + } + + /** + * 매니페스트의 공개 Tool 정의를 복사하고 내부 실행 정보인 {@code _meta}만 제거해 tools/list 노출본을 만듭니다. + */ + private JsonNode publicDefinition(JsonNode tool) { + ObjectNode definition = ((ObjectNode) tool).deepCopy(); + definition.remove("_meta"); + return definition; + } + + /** + * 설정에 선언된 모든 bundle의 현재 조회 상태를 반환합니다. 한 번도 조회에 성공하지 못한 bundle도 포함하므로, 설정의 기대값과 대조해 누락을 감지할 수 있습니다. + */ + public List statuses() { + return properties.bundles().stream() + .map( + bundle -> { + BundleState state = states.get(bundle.id()); + return state == null ? BundleStatus.never(bundle) : state.toStatus(bundle); + }) + .toList(); + } + + /** + * 한 bundle의 조회 결과이며, 노출할 Tool 목록과 사용 가능한 snapshot 여부를 함께 전달합니다. + */ + public record BundleResult(String bundleId, boolean usableSnapshot, List tools) { + + /** + * 노출할 Tool을 immutable copy로 고정합니다. + */ + public BundleResult { + tools = tools == null ? List.of() : List.copyOf(tools); + } + } + + /** + * Actuator가 반환할 bundle 하나의 조회 상태 요약입니다. 원문 payload와 오류 메시지는 포함하지 않습니다. + */ + public record BundleStatus( + String bundleId, + boolean enabled, + String status, + String revision, + int toolCount, + int consecutiveFailures, + String lastSuccessAt, + String lastFailureReason) { + + /** + * 설정에는 있으나 아직 한 번도 조회를 시도하지 않은 bundle의 상태를 만듭니다. 꺼 둔 bundle과 조회에 실패한 bundle은 원인이 다르므로 상태 문자열로 구분합니다. + */ + static BundleStatus never(Bundle bundle) { + String status = bundle.enabled() ? "unreachable" : "disabled"; + return new BundleStatus(bundle.id(), bundle.enabled(), status, null, 0, 0, null, null); + } + } + + /** + * bundle 하나의 마지막 성공 결과와 연속 실패 횟수를 보관하는 가변 상태입니다. 여러 조회 주기가 겹칠 수 있으므로 모든 갱신을 synchronized로 직렬화합니다. + */ + private static final class BundleState { + + private List lastGood; + private String revision; + private Instant lastSuccessAt; + private int consecutiveFailures; + private String lastFailureReason; + private boolean fallbackSnapshot; + + /** + * 조회 성공 결과를 채택하고 실패 상태를 모두 초기화합니다. + */ + synchronized BundleResult recordSuccess( + String bundleId, List tools, String revision) { + lastGood = tools; + this.revision = revision; + lastSuccessAt = Instant.now(); + consecutiveFailures = 0; + lastFailureReason = null; + fallbackSnapshot = false; + return new BundleResult(bundleId, true, tools); + } + + /** + * 최초 원격 조회 실패 후 local fallback을 사용 가능한 snapshot으로 채택합니다. 상태는 healthy로 위장하지 않고 fallback으로 남겨 운영자가 원격 원천 장애를 구분할 수 있게 합니다. + */ + synchronized BundleResult recordFallback( + String bundleId, List tools, String revision, String failureReason) { + lastGood = tools; + this.revision = revision; + lastSuccessAt = Instant.now(); + consecutiveFailures = 1; + lastFailureReason = failureReason; + fallbackSnapshot = true; + return new BundleResult(bundleId, true, tools); + } + + /** + * usable snapshot이 이미 있는지 동기화해 확인합니다. 이 값은 local fallback을 최초 기동에만 제한하는 기준이며, 일반 조회 상태를 바꾸지 않습니다. + */ + synchronized boolean hasSnapshot() { + return lastGood != null; + } + + /** + * 실패 횟수와 원인만 갱신하고 직전 성공본은 계속 노출합니다. 통신 실패만으로 Tool을 제거하지 않으며, 제거는 이후 정상 manifest에서 확인될 때만 반영합니다. + */ + synchronized BundleResult recordFailure(String bundleId, String reason) { + consecutiveFailures++; + lastFailureReason = reason; + return new BundleResult(bundleId, lastGood != null, lastGood == null ? List.of() : lastGood); + } + + /** + * 현재 보관 중인 상태를 Actuator 조회용 요약으로 변환합니다. + */ + synchronized BundleStatus toStatus(Bundle bundle) { + String status; + if (!bundle.enabled()) { + status = "disabled"; + } else if (fallbackSnapshot) { + status = "fallback"; + } else if (consecutiveFailures > 0) { + status = "degraded"; + } else if (lastGood != null) { + status = "healthy"; + } else { + status = "unreachable"; + } + return new BundleStatus( + bundle.id(), + bundle.enabled(), + status, + revision, + lastGood == null ? 0 : lastGood.size(), + consecutiveFailures, + Optional.ofNullable(lastSuccessAt).map(Instant::toString).orElse(null), + lastFailureReason); + } + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryClient.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryClient.java new file mode 100644 index 0000000..7f25e1a --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryClient.java @@ -0,0 +1,87 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleResult; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * 여러 Tool Service bundle의 조회 결과를 하나의 Tool 목록으로 병합하는 Registry 원천 adapter입니다. {@link ToolRegistryService}의 배경 갱신에서만 호출되며 요청 경로에는 관여하지 않습니다. 병합은 + * {@code (bundleId, name)} 오름차순 정렬로 마무리해 동시 조회의 응답 순서가 {@code tools/list} 순서를 바꾸지 않게 합니다. 주요 의존성은 bundle별 조회·검증·상태를 담당하는 {@link ToolBundleDiscovery}와 상한 + * 설정입니다. + */ +@Component +@ConditionalOnProperty(prefix = "mcp.discovery", name = "enabled", havingValue = "true") +public class ToolBundleRegistryClient implements ToolRegistryClient { + + private final ToolBundleDiscovery discovery; + private final McpProperties properties; + + /** + * bundle 조회 구성요소와 병합 상한 설정을 주입받습니다. + */ + public ToolBundleRegistryClient(ToolBundleDiscovery discovery, McpProperties properties) { + this.discovery = discovery; + this.properties = properties; + } + + /** + * 활성 bundle을 모두 조회한 뒤 검증을 통과한 Tool을 병합해 반환합니다. 각 bundle이 이번 조회 결과 또는 직전 성공본을 가져야 전체 snapshot을 확정합니다. 하나라도 사용 가능한 성공본이 없으면 Registry unavailable을 던져, + * {@link ToolRegistryService}가 기존 snapshot이나 공유 cache로 되돌아가게 합니다. + */ + @Override + public List fetchTools() { + List results = discovery.discoverAll(); + if (results.stream().anyMatch(result -> !result.usableSnapshot())) { + throw new JsonRpcException( + JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, + "At least one Tool bundle has no usable snapshot"); + } + return merge(results); + } + + /** + * bundle별 Tool을 이름 충돌과 총량 상한을 확인하며 하나의 정렬된 목록으로 합칩니다. 이름이 겹치거나 총량 상한을 넘으면 불완전한 목록을 만들지 않고 전체 갱신을 거부합니다. + */ + private List merge(List results) { + List candidates = new ArrayList<>(); + for (BundleResult result : results) { + result.tools().forEach(tool -> candidates.add(new BundleTool(result.bundleId(), tool))); + } + candidates.sort( + Comparator.comparing(BundleTool::bundleId).thenComparing(entry -> entry.tool().name())); + + int maxTotal = properties.discovery().maxToolsTotal(); + Set names = new HashSet<>(); + List merged = new ArrayList<>(); + for (BundleTool candidate : candidates) { + if (!names.add(candidate.tool().name())) { + throw new JsonRpcException( + JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, + "Duplicate Tool name across bundles: " + candidate.tool().name()); + } + if (merged.size() >= maxTotal) { + throw new JsonRpcException( + JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, + "Tool catalog exceeds maxToolsTotal: " + maxTotal); + } + merged.add(candidate.tool()); + } + return List.copyOf(merged); + } + + /** + * 정렬 기준인 소속 bundle을 Tool과 함께 들고 다니기 위한 병합 전용 임시 값입니다. + */ + private record BundleTool(String bundleId, ToolMetadata tool) { + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java new file mode 100644 index 0000000..68fbdb4 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java @@ -0,0 +1,27 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import tools.jackson.databind.JsonNode; + +/** + * 내부 Tool Registry가 관리하는 한 Tool 버전의 실행 metadata를 나타내는 불변 값 객체입니다. local {@code tools/list} 파일에서 온 경우 {@code publicDefinition}은 공개 필드를 보존하고, + * {@code tools/call}에는 endpoint·timeout·schema 정책까지 포함해 사용됩니다. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record ToolMetadata( + String name, + String version, + String description, + String endpoint, + JsonNode inputSchema, + Integer timeoutMillis, + boolean enabled, + JsonNode publicDefinition) { + + /** + * Tool별 timeout이 설정되어 있으면 사용하고, 없으면 공통 기본 timeout을 반환합니다. + */ + public int effectiveTimeoutMillis(int defaultTimeoutMillis) { + return timeoutMillis == null || timeoutMillis <= 0 ? defaultTimeoutMillis : timeoutMillis; + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java new file mode 100644 index 0000000..76bb8c5 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java @@ -0,0 +1,20 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import java.util.List; + +/** + * Tool metadata의 원천(source)을 읽는 역할입니다. + * + *

이 interface는 cache가 아닙니다. {@link ToolRegistryService}가 요청 경로에서는 memory snapshot을 읽고, cold + * start 또는 배경 refresh 때만 구현체를 호출합니다. local profile은 JSON 파일을, 운영 profile은 설정된 Tool Service bundle의 매니페스트 aggregate를 원천으로 사용합니다. Redis는 원천이 아니라 기동 warm start와 + * 성공 snapshot 공유에만 쓰는 선택적 cache입니다. + * + *

직접 MCP 요청을 처리하지 않는 outbound port이며, local 파일 구현과 운영 HTTP 구현을 profile에 따라 교체합니다. + */ +public interface ToolRegistryClient { + + /** + * 현재 profile의 원천에서 Tool 전체 목록을 읽어 immutable 목록으로 반환합니다. + */ + List fetchTools(); +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java new file mode 100644 index 0000000..5fdea0c --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java @@ -0,0 +1,89 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 시작 시점과 설정된 주기에 Tool Registry cache를 선행 갱신하는 scheduler입니다. 기동 preload는 즉시 실행하고, 여러 replica의 반복 조회 쏠림은 첫 scheduled 실행의 jitter로 분산합니다. MCP 요청을 직접 처리하지 않으며 + * {@link ToolRegistryService#refresh()}의 실패를 격리해 요청 시점의 정상 fallback을 보존합니다. 주요 의존성은 registry service와 Spring scheduling 설정입니다. + */ +@Component +public class ToolRegistryRefreshScheduler { + + private static final Logger logger = LoggerFactory.getLogger(ToolRegistryRefreshScheduler.class); + private final ToolRegistryService registryService; + private volatile boolean firstAttemptCompleted; + + /** + * Registry refresh를 실행할 service를 주입받습니다. + */ + public ToolRegistryRefreshScheduler(ToolRegistryService registryService) { + this.registryService = registryService; + } + + /** + * 애플리케이션 준비 직후 jitter 없이 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다. 먼저 다른 replica가 공유 cache에 남긴 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄이고, 이어서 원천을 조회해 최신 + * 상태로 교체합니다. 두 단계 모두 실패해도 애플리케이션은 계속 기동합니다. + */ + @EventListener(ApplicationReadyEvent.class) + public void preload() { + safeWarmStart(); + safeRefresh("preload"); + firstAttemptCompleted = true; + } + + /** + * 기동 직후 warm start와 원천 preload를 이미 시도했는지 알려 줍니다. readiness는 이 값과 {@link ToolRegistryService#hasUsableSnapshot()}을 함께 확인하므로, 실패하더라도 last-good snapshot이 있으면 + * 서비스하고 아무 snapshot도 없으면 트래픽을 받지 않습니다. + */ + public boolean firstAttemptCompleted() { + return firstAttemptCompleted; + } + + /** + * 공유 cache warm start 실패를 격리해 기동을 막지 않게 합니다. + */ + private void safeWarmStart() { + try { + registryService.warmStartFromSharedCache(); + } catch (RuntimeException exception) { + logger.warn( + "Tool Registry warm start failed: reason={}", exception.getClass().getSimpleName()); + } + } + + /** + * 설정된 간격마다 Tool Service manifest를 다시 읽어 cache snapshot을 갱신합니다. 첫 scheduled 실행에는 bounded random jitter를 더해 동시에 기동한 replica의 조회 시점을 분산합니다. + */ + @Scheduled( + fixedDelayString = "${mcp.registry.refresh-interval-seconds:30}", + initialDelayString = + "#{${mcp.registry.refresh-interval-seconds:30}" + + " + T(java.util.concurrent.ThreadLocalRandom).current()" + + ".nextLong(0, ${mcp.registry.refresh-jitter-seconds:5} + 1)}", + timeUnit = TimeUnit.SECONDS) + public void scheduledRefresh() { + safeRefresh("scheduled"); + } + + /** + * refresh 실패를 로그로 격리하여 scheduler나 애플리케이션이 중단되지 않게 합니다. + */ + private void safeRefresh(String trigger) { + try { + registryService.refresh(); + } catch (RuntimeException exception) { + // Cache preload/refresh is best-effort; request-time direct lookup remains available. + logger.warn( + "Tool Registry refresh failed: trigger={}, reason={}", + trigger, + exception.getClass().getSimpleName()); + } + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java new file mode 100644 index 0000000..297ab34 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java @@ -0,0 +1,170 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicReference; + +import org.springframework.stereotype.Service; + +/** + * Tool Registry metadata 조회의 단일 진입점이며 요청 경로와 배경 갱신 경로를 분리하는 서비스입니다. {@code tools/list}와 {@code tools/call}의 요청 경로는 in-memory snapshot만 읽으므로 Redis 장애나 지연이 응답에 + * 영향을 주지 않습니다. Redis는 배경 갱신과 warm start에서만 사용하는 replica 간 공유 지점이며, 원천 조회 성공 결과만 저장합니다. 주요 의존성은 원천 port {@link ToolRegistryClient}와 선택적 Redis cache입니다. + */ +@Service +public class ToolRegistryService { + + private final ToolRegistryClient registryClient; + private final Optional redisCache; + private final AtomicReference> snapshot = new AtomicReference<>(); + private final AtomicReference>> refreshInFlight = + new AtomicReference<>(); + + /** + * 원천 Registry와 memory·선택적 Redis 공유 cache를 주입받습니다. + */ + public ToolRegistryService( + ToolRegistryClient registryClient, Optional redisCache) { + this.registryClient = registryClient; + this.redisCache = redisCache; + } + + /** + * 활성 Tool 목록을 in-memory snapshot에서 읽습니다. 요청 경로에서는 Redis를 호출하지 않으므로 Redis 장애나 지연이 {@code tools/list} 응답 시간에 영향을 주지 않습니다. snapshot이 아직 비어 있는 기동 직후에만 원천을 한 번 + * 조회해 cold start 공백을 메웁니다. + */ + public List listTools() { + List memory = snapshot.get(); + if (memory != null) { + return memory; + } + return refresh(); + } + + /** + * 요청을 처리할 수 있는 Tool snapshot이 memory에 적재됐는지 반환합니다. 원천 또는 Redis에서 성공적으로 채택한 빈 목록도 유효한 전체 상태이므로 {@code null} 여부만 판단하며, readiness 확인 과정에서 Redis나 Tool Service를 + * 호출하지 않습니다. + */ + public boolean hasUsableSnapshot() { + return snapshot.get() != null; + } + + /** + * 기동 직후 다른 replica가 공유 지점에 저장해 둔 snapshot을 먼저 적재합니다. 첫 원천 조회가 끝나기 전의 빈 목록 구간을 줄이기 위한 best-effort 동작이며, 실패하거나 값이 없으면 아무것도 하지 않습니다. + */ + public void warmStartFromSharedCache() { + if (snapshot.get() != null) { + return; + } + redisCache + .flatMap(RedisToolRegistryCache::loadSnapshot) + .ifPresent(tools -> snapshot.compareAndSet(null, List.copyOf(tools))); + } + + /** + * 표준 Tool 이름이 일치하는 활성 Tool 하나를 찾습니다. cache가 오래됐을 수 있으므로 첫 조회에서 못 찾으면 Registry를 한 번 refresh한 뒤 최종 판단합니다. + */ + public ToolMetadata findEnabledTool(String name) { + List cached = listTools(); + Optional match = match(cached, name); + if (match.isPresent()) { + return match.get(); + } + + // A cache may be stale. Perform one direct lookup before declaring the tool missing. + try { + List refreshed = refresh(); + return match(refreshed, name).orElseThrow(() -> notFound(name)); + } catch (JsonRpcException exception) { + if (exception.errorCode() == JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE + && !cached.isEmpty()) { + throw notFound(name); + } + throw exception; + } + } + + /** + * Registry 원천을 직접 읽어 활성 Tool snapshot을 갱신합니다. 조회에 성공했을 때만 snapshot을 교체하고 공유 cache에 저장하므로, 실패가 기존 목록을 비우거나 다른 replica가 저장한 정상 snapshot을 덮어쓰지 않습니다. memory를 + * 먼저 갱신해 Redis 장애와 무관하게 최신 상태를 유지합니다. 원천 조회가 실패하면 기존 memory를 유지하고, memory가 비어 있을 때만 공유 cache를 채택합니다. + */ + public List refresh() { + CompletableFuture> candidate = new CompletableFuture<>(); + CompletableFuture> running = + refreshInFlight.compareAndExchange(null, candidate); + if (running != null) { + return awaitRefresh(running); + } + try { + List tools = refreshOnce(); + candidate.complete(tools); + return tools; + } catch (RuntimeException exception) { + candidate.completeExceptionally(exception); + throw exception; + } finally { + refreshInFlight.compareAndSet(candidate, null); + } + } + + /** + * Tool 원천을 한 번 조회하고 성공한 전체 snapshot만 memory와 Redis에 반영합니다. 원천 실패 시 기존 memory를 최우선으로 유지하고, memory가 비어 있을 때만 Redis last-good을 채택합니다. + */ + private List refreshOnce() { + try { + List tools = + registryClient.fetchTools().stream().filter(ToolMetadata::enabled).toList(); + snapshot.set(List.copyOf(tools)); + redisCache.ifPresent(cache -> cache.saveSnapshot(tools)); + return tools; + } catch (RuntimeException exception) { + List memory = snapshot.get(); + if (memory != null) { + return memory; + } + Optional> shared = + redisCache.flatMap(RedisToolRegistryCache::loadSnapshot); + if (shared.isPresent()) { + snapshot.set(List.copyOf(shared.get())); + return shared.get(); + } + throw exception; + } + } + + /** + * 다른 호출이 시작한 refresh 결과를 기다리며 원래 RuntimeException 유형을 보존합니다. 여러 cache miss가 동시에 발생해도 모든 호출자가 같은 source fetch 결과를 사용합니다. + */ + private List awaitRefresh(CompletableFuture> refresh) { + try { + return refresh.join(); + } catch (CompletionException exception) { + if (exception.getCause() instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw exception; + } + } + + /** + * 이름 조건으로 활성 Tool 후보를 찾습니다. 이름 중복은 원천 snapshot 병합 단계에서 거부됩니다. + */ + private Optional match(List tools, String name) { + return tools.stream() + .filter(ToolMetadata::enabled) + .filter(tool -> name.equals(tool.name())) + .findFirst(); + } + + /** + * 찾지 못한 Tool 이름을 포함한 Tool not found 예외를 만듭니다. + */ + private JsonRpcException notFound(String name) { + return new JsonRpcException( + JsonRpcErrorCode.TOOL_NOT_FOUND, "Tool not found or disabled: " + name); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClient.java b/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClient.java new file mode 100644 index 0000000..33912ac --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClient.java @@ -0,0 +1,186 @@ +package io.shinhanlife.dap.biz.mcp.toolclient; + +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; +import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException; +import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest; +import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse; + +import java.net.SocketTimeoutException; +import java.net.http.HttpClient; +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.StringNode; + +/** + * Tool Service로 HTTP 요청을 보내고 일반 JSON·text 응답을 내부 계약으로 정규화하는 outbound client입니다. Registry 기반 {@code tools/call} 실행이 이 구현을 사용하며, 요청 context의 correlation·사원 식별자 + * 헤더를 그대로 bypass하고 선택적 Authorization과 request deadline을 함께 전달합니다. 주요 의존성은 RestClient, 공유 JDK HttpClient, McpProperties, ObjectMapper입니다. + */ +@Component +public class HttpToolClient implements ToolClient { + + private final ObjectMapper objectMapper; + private final McpProperties properties; + private final HttpClient toolHttpClient; + + /** + * JSON 변환, Tool 설정, 공유 connection pool을 가진 HTTP client를 주입받습니다. + */ + public HttpToolClient( + ObjectMapper objectMapper, + McpProperties properties, + @Qualifier("toolHttpClient") HttpClient toolHttpClient) { + this.objectMapper = objectMapper; + this.properties = properties; + this.toolHttpClient = toolHttpClient; + } + + /** + * ToolRequest를 POST HTTP 요청으로 보내고 응답 body를 JsonNode로 정규화합니다. correlation 헤더를 전달하며 timeout·401·403·기타 HTTP 오류를 구분한 예외로 변환합니다. + */ + @Override + public ToolResponse execute(ToolRequest request, McpRequestContext context) { + try { + RestClient.RequestBodySpec spec = requestSpec(request, context); + var entity = + spec.retrieve() + .onStatus( + HttpStatusCode::isError, + (httpRequest, response) -> { + throw statusException(response.getStatusCode().value(), request.toolName()); + }) + .toEntity(String.class); + return new ToolResponse( + entity.getStatusCode().value(), + parseResponse(entity.getBody(), entity.getHeaders().getContentType())); + } catch (ToolClientException exception) { + throw exception; + } catch (ResourceAccessException exception) { + if (hasTimeoutCause(exception)) { + throw new ToolClientException( + ToolClientException.Kind.TIMEOUT, "Tool timed out: " + request.toolName(), exception); + } + throw executionException(request, exception); + } catch (RestClientException | IllegalArgumentException exception) { + throw executionException(request, exception); + } + } + + /** + * URI, bypass 헤더와 JSON body를 조합해 실행 직전 POST 요청 객체를 만듭니다. Agent Builder가 보낸 correlation·사원 식별자는 이름과 값을 바꾸지 않고 그대로 실어 보냅니다. 사원 식별자는 암호문이며 MCP는 복호화하지 않으므로, 이 + * 경계에서는 전달만 하고 해석하지 않습니다. + */ + private RestClient.RequestBodySpec requestSpec(ToolRequest request, McpRequestContext context) { + RestClient client = clientFor(remainingTimeoutMillis(request, context)); + return client + .post() + .uri(request.endpoint()) + .headers( + headers -> { + set(headers, "x-request-id", context.requestId()); + set(headers, "guid", context.guid()); + set(headers, "employee-no", context.employeeNo()); + set(headers, "virtual-employee-no", context.virtualEmployeeNo()); + set(headers, "mcp-session-id", context.mcpSessionId()); + if (properties.toolClient().forwardAuthorization()) { + set(headers, "Authorization", context.authorization()); + } + }) + .contentType(MediaType.APPLICATION_JSON) + .body(request.arguments()); + } + + /** + * 공유 JDK HttpClient 위에 이번 호출의 read timeout만 적용한 경량 RestClient를 만듭니다. + */ + private RestClient clientFor(int readTimeoutMillis) { + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(toolHttpClient); + factory.setReadTimeout(Duration.ofMillis(readTimeoutMillis)); + return RestClient.builder().requestFactory(factory).build(); + } + + /** + * Tool timeout과 전체 MCP deadline 중 더 짧은 남은 시간을 실제 read timeout으로 선택합니다. + */ + private int remainingTimeoutMillis(ToolRequest request, McpRequestContext context) { + long remainingMillis = context.remainingMillis(); + if (remainingMillis <= 0) { + throw new ToolClientException( + ToolClientException.Kind.TIMEOUT, + "MCP request deadline exceeded before calling Tool: " + request.toolName(), + null); + } + return (int) Math.min(request.timeoutMillis(), remainingMillis); + } + + /** + * 값이 null이 아닐 때만 HTTP 헤더를 설정해 문자열 `null`이 전달되지 않게 합니다. + */ + private void set(org.springframework.http.HttpHeaders headers, String name, String value) { + if (value != null) { + headers.set(name, value); + } + } + + /** + * upstream HTTP 상태를 권한 오류 또는 일반 실행 오류 ToolClientException으로 변환합니다. + */ + private ToolClientException statusException(int status, String toolName) { + ToolClientException.Kind kind = + switch (status) { + case 401 -> ToolClientException.Kind.UNAUTHORIZED; + case 403 -> ToolClientException.Kind.FORBIDDEN; + default -> ToolClientException.Kind.EXECUTION; + }; + return new ToolClientException(kind, "Tool returned HTTP " + status + ": " + toolName, null); + } + + /** + * 네트워크·직렬화 등 일반 client 예외를 Tool 이름이 포함된 실행 실패로 감쌉니다. + */ + private ToolClientException executionException(ToolRequest request, Exception exception) { + return new ToolClientException( + ToolClientException.Kind.EXECUTION, "Tool call failed: " + request.toolName(), exception); + } + + /** + * 예외 cause chain 전체를 따라가며 실제 socket timeout이 포함되어 있는지 확인합니다. + */ + private boolean hasTimeoutCause(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof SocketTimeoutException) { + return true; + } + current = current.getCause(); + } + return false; + } + + /** + * Content-Type이 JSON이면 body를 JSON으로 파싱하고 그 외에는 text로 보존합니다. JSON이라고 표시됐지만 파싱에 실패한 경우에도 응답을 잃지 않고 text로 반환합니다. + */ + private JsonNode parseResponse(String body, MediaType contentType) { + if (body == null) { + return null; + } + if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) { + return StringNode.valueOf(body); + } + try { + return objectMapper.readTree(body); + } catch (Exception ignored) { + return StringNode.valueOf(body); + } + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/ToolClient.java b/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/ToolClient.java new file mode 100644 index 0000000..7e45ac2 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/ToolClient.java @@ -0,0 +1,63 @@ +package io.shinhanlife.dap.biz.mcp.toolclient; + +import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; +import tools.jackson.databind.JsonNode; + +/** + * 실제 Tool Service 호출을 실행 계층에서 분리하기 위한 outbound port입니다. {@link io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService}가 이 계약에 의존하며, 구현체는 HTTP·오류 종류를 표준화해 + * 반환합니다. + */ +public interface ToolClient { + + /** + * ToolRequest를 한 번 실행하고 HTTP 상태와 응답 data를 반환하는 기본 Tool 호출 port입니다. + */ + ToolResponse execute(ToolRequest request, McpRequestContext context); + + /** + * Tool Service로 전달할 URL, arguments, timeout을 묶는 불변 요청 값 객체입니다. + */ + record ToolRequest( + String toolName, String version, String endpoint, JsonNode arguments, int timeoutMillis) { + } + + /** + * Tool Service 응답의 HTTP 상태와 JSON 또는 text로 정규화한 본문을 담는 불변 값 객체입니다. + */ + record ToolResponse(int statusCode, JsonNode data) { + } + + /** + * upstream Tool 호출의 timeout·권한·일반 실행 실패를 실행 계층이 구분할 수 있게 전달하는 예외입니다. {@code ToolsCallHandler}는 이 정보를 거쳐 Tool 실패를 JSON-RPC error가 아닌 {@code result.isError}로 + * 응답합니다. + */ + final class ToolClientException extends RuntimeException { + + /** + * 실행 계층이 timeout·권한 거부·기타 호출 실패를 서로 다른 MCP 오류 의미로 바꾸기 위한 실패 분류입니다. + */ + public enum Kind { + TIMEOUT, + UNAUTHORIZED, + FORBIDDEN, + EXECUTION + } + + private final Kind kind; + + /** + * 실패 종류, 안전한 메시지와 원인 예외를 보존합니다. + */ + public ToolClientException(Kind kind, String message, Throwable cause) { + super(message, cause); + this.kind = kind; + } + + /** + * timeout·권한·일반 실행 중 어떤 종류의 실패인지 반환합니다. + */ + public Kind kind() { + return kind; + } + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/CachedBodyHttpServletRequest.java b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/CachedBodyHttpServletRequest.java new file mode 100644 index 0000000..6f84430 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/CachedBodyHttpServletRequest.java @@ -0,0 +1,107 @@ +package io.shinhanlife.dap.biz.mcp.transport.http; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +/** + * 설정된 MCP endpoint의 요청 본문을 제한된 크기로 메모리에 복사하고 filter와 controller가 각각 다시 읽게 하는 servlet request wrapper입니다. {@link McpExchangeFilter}가 method 확인과 입력 크기 제한을 위해 만들며, 본문이 + * 한도를 넘으면 controller까지 전달하지 않고 차단합니다. 주요 의존성은 Servlet request/stream API뿐이며, JSON-RPC method의 관찰용 해석은 {@link McpExchangeFilter}가 담당합니다. + */ +final class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { + + /** + * 읽어 둔 요청 본문. 이 클래스 밖으로 배열 자체를 넘기지 않고 스트림으로만 노출합니다. + */ + private final byte[] body; + + /** + * 원본 요청 본문을 설정된 최대 크기까지만 메모리에 읽어 둡니다. + * + *

한도보다 1 byte 더 읽는 이유는, 전체를 다 읽어 본 뒤에 크기를 재면 거대한 요청이 이미 메모리에 올라온 뒤이기 때문입니다. 한도+1을 읽어 그 + * 길이가 한도를 넘으면 나머지는 읽지 않고 바로 차단합니다. + * + * @param maxBodyBytes 허용할 본문 최대 byte 수 + * @throws RequestBodyTooLargeException 본문이 한도를 넘어 controller까지 보내지 않고 끊을 때 + */ + CachedBodyHttpServletRequest(HttpServletRequest request, int maxBodyBytes) throws IOException { + super(request); + byte[] candidate = request.getInputStream().readNBytes(maxBodyBytes + 1); + if (candidate.length > maxBodyBytes) { + throw new RequestBodyTooLargeException(maxBodyBytes); + } + this.body = candidate; + } + + /** + * 요청 본문을 읽을 수 있는 스트림을 매번 새로 만들어 돌려줍니다. + * + *

이 wrapper가 존재하는 이유가 여기에 있습니다. 원래 HTTP 요청 본문은 네트워크에서 흘러오는 스트림이라 한 번 읽으면 끝입니다. 그런데 이 + * 서버는 같은 본문을 두 번 봐야 합니다. filter가 로그·검증용으로 JSON-RPC {@code method}를 먼저 읽고, 그 다음 controller가 전체를 다시 읽어 파싱합니다. 미리 byte 배열에 담아 두고 요청할 때마다 그 배열 위에 새 스트림을 얹어 주면 + * 두 번 읽어도 문제가 없습니다. + */ + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream input = new ByteArrayInputStream(body); + return new ServletInputStream() { + /** 한 byte를 읽어 반환합니다. 더 읽을 것이 없으면 {@code -1}입니다. */ + @Override + public int read() { + return input.read(); + } + + /** 본문을 끝까지 읽었는지 알려 줍니다. 메모리 배열이라 남은 byte 수로 바로 판단합니다. */ + @Override + public boolean isFinished() { + return input.available() == 0; + } + + /** 지금 바로 읽어도 되는지 알려 줍니다. 네트워크가 아니라 이미 메모리에 있는 데이터이므로 기다릴 일이 없어 항상 {@code true}입니다. */ + @Override + public boolean isReady() { + return true; + } + + /** + * 비동기(non-blocking) 읽기 콜백 등록을 거부합니다. 이 서버는 요청을 동기로만 처리하므로, 누군가 비동기로 읽으려 하면 조용히 동작하는 대신 즉시 예외를 + * 던져 잘못된 사용을 드러냅니다. + */ + @Override + public void setReadListener(ReadListener readListener) { + throw new UnsupportedOperationException("Async request body reading is not supported"); + } + }; + } + + /** + * 요청의 문자 인코딩에 맞는 Reader를 반환합니다. 문자 인코딩이 없으면 JSON의 기본 인코딩인 UTF-8을 사용합니다. + */ + @Override + public BufferedReader getReader() { + String encoding = getCharacterEncoding(); + Charset charset = encoding == null ? StandardCharsets.UTF_8 : Charset.forName(encoding); + return new BufferedReader(new InputStreamReader(getInputStream(), charset)); + } + + /** + * 요청 본문이 설정된 한도를 넘었음을 알리는 내부 전용 예외입니다. {@link McpExchangeFilter}가 이 예외를 잡아 JSON-RPC {@code -32600 Invalid Request}로 바꾸며, controller까지 요청이 전달되지 않습니다. 이 클래스 + * 밖에서는 만들 수 없습니다. + */ + static final class RequestBodyTooLargeException extends IOException { + + /** + * 한도 값을 메시지에 담아, 로그만 보고도 어떤 설정 때문에 막혔는지 알 수 있게 합니다. + */ + private RequestBodyTooLargeException(int maxBodyBytes) { + super("MCP request body exceeds configured maximum of " + maxBodyBytes + " bytes"); + } + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpController.java b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpController.java new file mode 100644 index 0000000..d6d4c48 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpController.java @@ -0,0 +1,76 @@ +package io.shinhanlife.dap.biz.mcp.transport.http; + +import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequestParser; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse; +import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry; + +import java.util.UUID; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import tools.jackson.databind.JsonNode; + +/** + * 외부 AgentBuilder가 배포 설정에 등록한 단일 MCP HTTP 경로를 rewrite 없이 처리하는 controller입니다. JSON-RPC 요청을 parser로 검증하고 handler로 dispatch하며, notification HTTP 202, initialize 세션 correlation + * 헤더, JSON 응답을 조립합니다. 주요 의존성은 endpoint 설정, request parser, handler registry와 request context입니다. + */ +@RestController +public class McpController { + + public static final String MCP_SESSION_ID_HEADER = "Mcp-Session-Id"; + + private final JsonRpcRequestParser requestParser; + private final McpMethodHandlerRegistry handlerRegistry; + + /** + * JSON-RPC 변환과 method dispatch 협력 객체를 주입받습니다. Controller는 실행 규칙을 직접 구현하지 않고 각 책임 객체를 올바른 순서로 연결합니다. + */ + public McpController( + JsonRpcRequestParser requestParser, McpMethodHandlerRegistry handlerRegistry) { + this.requestParser = requestParser; + this.handlerRegistry = handlerRegistry; + } + + /** + * 배포별 단일 MCP POST 요청을 받아 JSON-RPC 변환 후 알맞은 handler로 전달합니다. initialize에는 새 correlation header를 발급하고 notification은 HTTP 202, 일반 요청은 HTTP 200으로 응답합니다. mapping의 + * {@code text/event-stream}은 기존 Agent Builder Accept header를 수용하기 위한 media type일 뿐이며, 이 method는 streaming body를 만들지 않고 항상 단일 JSON 또는 빈 notification 응답을 + * 반환합니다. + */ + @PostMapping( + value = "${mcp.endpoint-path:/mcp}", + consumes = {MediaType.APPLICATION_JSON_VALUE, "application/json-rpc"}, + produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.TEXT_EVENT_STREAM_VALUE}) + public ResponseEntity handleMcpRequest(@RequestBody JsonNode envelope) { + McpRequestContext context = McpRequestContextHolder.require(); + JsonRpcRequest request = requestParser.parse(envelope); + try { + JsonRpcResponse response = handlerRegistry.resolve(request.method()).handle(request, context); + if (request.notification()) { + return ResponseEntity.accepted().build(); + } + ResponseEntity.BodyBuilder responseBuilder = + ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON); + if ("initialize".equals(request.method())) { + responseBuilder.header(MCP_SESSION_ID_HEADER, UUID.randomUUID().toString()); + } + return responseBuilder.body(response); + } catch (JsonRpcException exception) { + if (exception.requestId() != null) { + throw exception; + } + throw new JsonRpcException( + exception.errorCode(), exception.errorData(), request.id(), exception); + } catch (RuntimeException exception) { + throw new JsonRpcException( + JsonRpcErrorCode.INTERNAL_ERROR, "Unexpected server error", request.id(), exception); + } + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandler.java b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandler.java new file mode 100644 index 0000000..6fdd450 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandler.java @@ -0,0 +1,87 @@ +package io.shinhanlife.dap.biz.mcp.transport.http; + +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse; +import io.shinhanlife.dap.biz.mcp.observability.TraceLogger; + +import java.util.Set; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * {@link McpController} 처리 중 발생한 예외를 AgentBuilder가 해석할 JSON-RPC 오류 응답으로 정규화하는 전용 예외 처리기입니다. 설정된 MCP endpoint의 malformed JSON, 검증 오류, 예상치 못한 controller 오류를 HTTP 200 + * 안의 JSON-RPC error envelope로 반환합니다. Filter 단계의 크기·헤더·protocol 오류는 MVC에 도달하지 않으므로 {@link McpExchangeFilter}가 직접 응답합니다. 다만 지원하지 않는 HTTP method는 JSON-RPC 이전의 + * transport 문제이므로 표준 HTTP 405로 응답합니다. 주요 의존성은 오류 코드 factory와 {@link TraceLogger}이며, Tool 실행 실패의 {@code result.isError} 변환은 이 클래스가 아니라 {@code ToolsCallHandler}가 + * 담당합니다. + */ +@RestControllerAdvice(assignableTypes = McpController.class) +public class McpExceptionHandler { + + private final TraceLogger traceLogger; + + /** + * 모든 오류를 같은 trace 형식으로 기록하기 위해 logger를 주입받습니다. + */ + public McpExceptionHandler(TraceLogger traceLogger) { + this.traceLogger = traceLogger; + } + + /** + * 서버가 의도적으로 발생시킨 JSON-RPC 예외를 HTTP 200의 표준 실패 응답으로 변환합니다. + */ + @ExceptionHandler(JsonRpcException.class) + public ResponseEntity handleJsonRpcException(JsonRpcException exception) { + traceLogger.error("error_occurred", exception, "errorCode", exception.errorCode().code()); + return ResponseEntity.ok( + JsonRpcResponse.failure( + exception.requestId(), exception.errorCode(), exception.errorData())); + } + + /** + * JSON 문법이 잘못되어 body를 읽지 못한 경우 Parse error 응답을 반환합니다. + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleParseError( + HttpMessageNotReadableException exception) { + traceLogger.error( + "error_occurred", exception, "errorCode", JsonRpcErrorCode.PARSE_ERROR.code()); + return ResponseEntity.ok( + JsonRpcResponse.failure(null, JsonRpcErrorCode.PARSE_ERROR, "Malformed JSON request body")); + } + + /** + * 설정된 MCP endpoint가 허용하지 않는 HTTP method 요청을 JSON-RPC 오류가 아니라 표준 HTTP 405로 반환합니다. MCP 클라이언트는 server-push 수신용 GET이나 세션 종료용 DELETE를 시도할 수 + * 있는데, 이 서버는 POST 단일 경로만 제공하므로 지원 method를 {@code Allow} 헤더로 알려 클라이언트가 재시도하지 않게 합니다. 이 응답은 JSON-RPC 이전 단계의 transport 계약이므로 본문 없이 상태 코드와 헤더만 반환합니다. + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotAllowed( + HttpRequestMethodNotSupportedException exception) { + traceLogger.error( + "mcp_http_method_not_allowed", exception, "httpMethod", exception.getMethod()); + HttpHeaders headers = new HttpHeaders(); + Set supportedMethods = exception.getSupportedHttpMethods(); + if (supportedMethods != null && !supportedMethods.isEmpty()) { + headers.setAllow(supportedMethods); + } + return new ResponseEntity<>(headers, HttpStatus.METHOD_NOT_ALLOWED); + } + + /** + * 예상하지 못한 예외의 내부 내용을 숨기고 안전한 Internal error 응답으로 변환합니다. + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpected(Exception exception) { + traceLogger.error( + "error_occurred", exception, "errorCode", JsonRpcErrorCode.INTERNAL_ERROR.code()); + return ResponseEntity.ok( + JsonRpcResponse.failure(null, JsonRpcErrorCode.INTERNAL_ERROR, "Unexpected server error")); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilter.java b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilter.java new file mode 100644 index 0000000..763bfb3 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilter.java @@ -0,0 +1,214 @@ +package io.shinhanlife.dap.biz.mcp.transport.http; + +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse; +import io.shinhanlife.dap.biz.mcp.observability.TraceLogger; +import io.shinhanlife.dap.biz.mcp.transport.http.McpProtocolVersionValidator.ProtocolVersionException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.util.Map; + +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * 배포 설정의 단일 MCP HTTP 경로에서 요청·응답 경계를 처리하는 필터입니다. Agent Builder가 보낸 guid와 개별 HTTP requestId를 context와 응답 헤더에 연결하고, 요청 크기와 protocol version을 Controller 전에 검증합니다. + * payload, credential, 사원 식별자는 로그에 저장하지 않습니다. 주요 의존성은 endpoint 설정, header 추출기, JSON mapper, protocol validator와 {@link TraceLogger}입니다. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 10) +public class McpExchangeFilter extends OncePerRequestFilter { + + private final McpRequestContextFactory headerExtractor; + private final TraceLogger traceLogger; + private final ObjectMapper objectMapper; + private final McpProperties properties; + private final McpProtocolVersionValidator protocolVersionValidator; + + /** + * 요청 correlation, 최소 JSON 관찰, 경계 로그와 protocol 검증에 필요한 객체를 주입받습니다. 별도 payload capture나 audit sink는 조립하지 않습니다. + */ + public McpExchangeFilter( + McpRequestContextFactory headerExtractor, + TraceLogger traceLogger, + ObjectMapper objectMapper, + McpProperties properties, + McpProtocolVersionValidator protocolVersionValidator) { + this.headerExtractor = headerExtractor; + this.traceLogger = traceLogger; + this.objectMapper = objectMapper; + this.properties = properties; + this.protocolVersionValidator = protocolVersionValidator; + } + + /** + * 설정된 MCP endpoint 이외의 다른 MCP·health·management 요청은 correlation 처리와 MCP 로그 대상에서 제외합니다. + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + String contextPath = request.getContextPath(); + if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) { + path = path.substring(contextPath.length()); + } + return !properties.endpointPath().equals(path); + } + + /** + * MCP HTTP 요청 수명 동안 context를 설정하고 요청·응답 경계 로그를 남긴 뒤 반드시 ThreadLocal을 정리합니다. + * + *

처리 순서는 다음과 같습니다. + * + *

    + *
  1. 헤더에서 correlation 값을 뽑아 context를 만들고 응답 헤더에 먼저 심는다(오류 응답에도 실리도록) + *
  2. 본문을 크기 제한과 함께 읽어 다시 읽을 수 있는 wrapper로 감싼다 + *
  3. 로그·검증용으로 JSON-RPC method만 미리 확인한다 + *
  4. protocol version을 검증하고, 실패하면 controller까지 가지 않고 HTTP 400으로 끝낸다 + *
  5. controller 체인을 실행하고 응답 완료 로그를 남긴다 + *
+ * + *

어떤 경로로 끝나든 {@code finally}에서 ThreadLocal을 지웁니다. thread는 다음 요청에 재사용되므로, 지우지 않으면 이전 요청의 사용자 + * 정보가 섞입니다. + */ + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + long startedNanos = System.nanoTime(); + try { + McpRequestContext context = headerExtractor.extract(request); + McpRequestContextHolder.set(context); + response.setHeader("guid", context.guid()); + response.setHeader("x-request-id", context.requestId()); + + CachedBodyHttpServletRequest cachedRequest = + new CachedBodyHttpServletRequest(request, properties.trace().maxBodyBytes()); + String mcpMethod = extractMethod(cachedRequest); + traceLogger.event( + "mcp_http_request_received", + "httpMethod", + request.getMethod(), + "path", + request.getRequestURI(), + "mcpMethod", + mcpMethod); + + try { + protocolVersionValidator.validatePostInitializeRequest(request, mcpMethod); + } catch (ProtocolVersionException exception) { + writeProtocolVersionError(response, context, exception); + traceLogger.event( + "mcp_http_response_completed", + "mcpMethod", + mcpMethod, + "httpStatus", + response.getStatus(), + "durationMillis", + elapsedMillis(startedNanos)); + return; + } + + filterChain.doFilter(cachedRequest, response); + traceLogger.event( + "mcp_http_response_completed", + "mcpMethod", + mcpMethod, + "httpStatus", + response.getStatus(), + "durationMillis", + elapsedMillis(startedNanos)); + } catch (CachedBodyHttpServletRequest.RequestBodyTooLargeException exception) { + traceLogger.error( + "mcp_http_request_rejected", + exception, + "maxBodyBytes", + properties.trace().maxBodyBytes()); + writeJsonRpcError(response, JsonRpcErrorCode.INVALID_REQUEST, exception.getMessage()); + } catch (JsonRpcException exception) { + traceLogger.error( + "mcp_http_request_rejected", exception, "errorCode", exception.errorCode().code()); + writeJsonRpcError(response, exception.errorCode(), exception.errorData()); + } catch (IOException exception) { + // 여기까지 온 IOException은 대개 "쓰려는데 상대가 이미 끊었다"(broken pipe)다. + // Agent Builder는 응답을 5분 이상 기다리지 않으므로, 오래 걸린 Tool 결과가 이 경로로 버려진다. + // 조용히 사라지면 나중에 추적이 불가능하므로 guid와 함께 별도 event로 남긴다. + // Tool은 이미 실행됐을 수 있다. 재시도 중복 실행 방지는 Tool Service 몫이며 guid 재사용 규칙은 별도 합의 대상이다. + traceLogger.error( + "mcp_http_response_undeliverable", + exception, + "durationMillis", + elapsedMillis(startedNanos)); + throw exception; + } finally { + McpRequestContextHolder.clear(); + } + } + + /** + * 경계 로그와 protocol 검증에 필요한 JSON-RPC {@code method} 이름만 미리 읽습니다. + * + *

여기서 읽어도 controller가 같은 본문을 다시 읽을 수 있습니다. {@link CachedBodyHttpServletRequest}가 호출할 때마다 새 + * 스트림을 만들어 주기 때문입니다. + * + *

JSON이 깨져 있어도 예외를 던지지 않고 {@code null}을 돌려줍니다. 이 단계는 관찰이 목적이고, 잘못된 JSON을 어떤 오류로 응답할지는 + * 뒤쪽 request adapter가 정하기 때문입니다. 여기서 먼저 실패시키면 오류 계약이 두 곳으로 갈라집니다. + * + * @return method 이름. 읽을 수 없으면 {@code null} + */ + private String extractMethod(CachedBodyHttpServletRequest request) { + try { + JsonNode envelope = objectMapper.readTree(request.getInputStream()); + return envelope == null ? null : envelope.path("method").asString(null); + } catch (Exception ignored) { + return null; + } + } + + /** + * 필터 단계의 JSON-RPC 오류를 현재 외부 계약인 HTTP 200 JSON error envelope로 작성합니다. + */ + private void writeJsonRpcError( + HttpServletResponse response, JsonRpcErrorCode code, Object details) throws IOException { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue( + response.getOutputStream(), JsonRpcResponse.failure(null, code, details)); + } + + /** + * initialize 이후 protocol version 헤더 누락·불일치를 HTTP 400 transport 오류로 작성합니다. + */ + private void writeProtocolVersionError( + HttpServletResponse response, McpRequestContext context, ProtocolVersionException exception) + throws IOException { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue( + response.getOutputStream(), + Map.of( + "error", "Invalid MCP protocol version", + "message", exception.getMessage(), + "supportedVersions", properties.protocol().supportedVersions(), + "guid", context.guid())); + } + + /** + * 요청 시작 이후 경과 시간을 monotonic clock 기준 밀리초로 반환합니다. + */ + private long elapsedMillis(long startedNanos) { + return (System.nanoTime() - startedNanos) / 1_000_000L; + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidator.java b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidator.java new file mode 100644 index 0000000..053c3d3 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidator.java @@ -0,0 +1,55 @@ +package io.shinhanlife.dap.biz.mcp.transport.http; + +import io.modelcontextprotocol.spec.McpSchema; +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * initialize 이후 MCP 요청이 합의된 protocol version 헤더를 선언했는지 검사하는 stateless 정책 컴포넌트입니다. 설정된 MCP endpoint의 가장 앞단 filter가 호출하며 initialize 자체는 협상 단계이므로 검사 대상에서 제외합니다. 주요 + * 의존성은 지원·선호 버전 목록을 제공하는 {@link McpProperties}와 MCP SDK method 상수입니다. + */ +@Component +public class McpProtocolVersionValidator { + + public static final String HEADER_NAME = "MCP-Protocol-Version"; + + private final McpProperties properties; + + /** + * 서버가 지원하는 MCP protocol version 설정을 주입받습니다. + */ + public McpProtocolVersionValidator(McpProperties properties) { + this.properties = properties; + } + + /** + * initialize 이후 요청에 MCP-Protocol-Version 헤더가 있는지, 지원 목록과 일치하는지 검사합니다. initialize 자체는 아직 version을 협상하는 단계이므로 검사하지 않습니다. + */ + public void validatePostInitializeRequest(HttpServletRequest request, String mcpMethod) { + if (mcpMethod == null || McpSchema.METHOD_INITIALIZE.equals(mcpMethod)) { + return; + } + String version = request.getHeader(HEADER_NAME); + if (!StringUtils.hasText(version)) { + throw new ProtocolVersionException(HEADER_NAME + " header is required after initialize"); + } + if (!properties.protocol().supportedVersions().contains(version.trim())) { + throw new ProtocolVersionException("Unsupported " + HEADER_NAME + ": " + version.trim()); + } + } + + /** + * protocol version 헤더 누락 또는 미지원 값을 filter가 JSON-RPC 오류로 변환하도록 전달하는 내부 예외입니다. 별도의 HTTP 응답을 만들지 않으며, 최종 응답 형식은 {@code McpExchangeFilter}의 책임입니다. + */ + public static final class ProtocolVersionException extends RuntimeException { + + /** + * 호출자에게 알려 줄 protocol version 거절 이유를 보존합니다. + */ + public ProtocolVersionException(String message) { + super(message); + } + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRequestContextFactory.java b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRequestContextFactory.java new file mode 100644 index 0000000..6680ef4 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRequestContextFactory.java @@ -0,0 +1,135 @@ +package io.shinhanlife.dap.biz.mcp.transport.http; + +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import jakarta.servlet.http.HttpServletRequest; + +import java.time.Instant; +import java.util.UUID; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * 설정된 MCP endpoint의 HTTP 헤더를 읽어 {@link McpRequestContext}를 만드는 입력 경계 컴포넌트입니다. filter의 가장 앞 단계에서 호출되며 {@code guid}·{@code x-request-id}를 생성 또는 검증하고, + * {@code mcp-session-id}·사원 식별자·deadline을 함께 정리합니다. 사원 식별자는 호출자가 암호화해 보낸 불투명 값이므로 형식·의미를 해석하지 않고 주입 위험 문자만 차단합니다. 주요 의존성은 timeout 설정 {@link McpProperties}이며, + * Authorization 원문은 context 전달 외에는 로그에 남기지 않습니다. + */ +@Component +public class McpRequestContextFactory { + + private static final Pattern SAFE_CORRELATION_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + /** + * 암호문은 Base64라 {@code +/=}를 포함한다. 공백·제어문자만 막아 header 주입을 차단하고 내용은 해석하지 않는다. + */ + private static final Pattern SAFE_OPAQUE_TOKEN = Pattern.compile("[\\x21-\\x7E]{1,2048}"); + + private final McpProperties properties; + + /** + * 요청 전체 timeout 설정을 주입받습니다. + */ + public McpRequestContextFactory(McpProperties properties) { + this.properties = properties; + } + + /** + * HTTP 헤더를 읽어 correlation·세션·사원 식별자를 하나의 immutable context로 만듭니다. 다섯 헤더 모두 선택값이며, 로그 상관이 끊기지 않도록 {@code guid}와 {@code x-request-id}만 없을 때 새로 만듭니다. 전체 요청 + * deadline도 이 시점에 계산합니다. + */ + public McpRequestContext extract(HttpServletRequest request) { + String authorization = trimToNull(request.getHeader("Authorization")); + String requestId = validatedRequestIdOrGenerated(request.getHeader("x-request-id")); + String guid = validatedGuidOrGenerated(request.getHeader("guid")); + String sessionId = validatedOptional(request.getHeader("mcp-session-id"), "mcp-session-id"); + String employeeNo = opaqueOptional(request.getHeader("employee-no"), "employee-no"); + String virtualEmployeeNo = + opaqueOptional(request.getHeader("virtual-employee-no"), "virtual-employee-no"); + + return new McpRequestContext( + requestId, + guid, + sessionId, + employeeNo, + virtualEmployeeNo, + authorization, + Instant.now().plusMillis(properties.toolClient().requestDeadlineMillis())); + } + + /** + * {@code x-request-id}가 있으면 안전성을 검증하고, 없으면 {@code req-UUID} 형식으로 새 값을 만듭니다. 이 값은 개별 HTTP 요청을 구분하며 end-to-end 상관 값인 {@code guid}와 역할이 다릅니다. + */ + private String validatedRequestIdOrGenerated(String value) { + String normalized = trimToNull(value); + if (normalized == null) { + return "req-" + UUID.randomUUID(); + } + validate(normalized, "x-request-id"); + return normalized; + } + + /** + * {@code guid}가 없으면 표준 UUID를 만들고, 있으면 축약형이나 임의 문자열이 아닌 정규 UUID인지 확인합니다. Agent Builder가 보낸 값은 변경하지 않고 그대로 응답과 Tool Service 호출에 사용합니다. + */ + private String validatedGuidOrGenerated(String value) { + if (value == null || value.isEmpty()) { + return UUID.randomUUID().toString(); + } + try { + if (!UUID.fromString(value).toString().equalsIgnoreCase(value)) { + throw new IllegalArgumentException("non-canonical UUID"); + } + return value; + } catch (IllegalArgumentException exception) { + throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "guid must be a UUID"); + } + } + + /** + * 암호화된 사원 식별자처럼 MCP가 해석하지 않는 값을 검증합니다. 값의 의미는 보지 않고, 개행·공백이 섞여 downstream 요청 헤더가 조작되는 것만 막습니다. 빈 값은 선택 헤더가 없는 것으로 취급하고 실제 암호문은 한 글자도 변경하지 않습니다. + */ + private String opaqueOptional(String value, String header) { + if (value == null || value.isEmpty()) { + return null; + } + if (!SAFE_OPAQUE_TOKEN.matcher(value).matches()) { + throw new JsonRpcException( + JsonRpcErrorCode.INVALID_REQUEST, + header + " must be a single-line token of at most 2048 printable characters"); + } + return value; + } + + /** + * 선택 헤더는 값이 있을 때만 형식 검증을 수행하고, 없으면 null을 반환합니다. + */ + private String validatedOptional(String value, String header) { + String normalized = trimToNull(value); + if (normalized != null) { + validate(normalized, header); + } + return normalized; + } + + /** + * correlation 값이 허용된 문자와 1~128자 길이 규칙을 지키는지 검사합니다. + */ + private void validate(String value, String header) { + if (!SAFE_CORRELATION_ID.matcher(value).matches()) { + throw new JsonRpcException( + JsonRpcErrorCode.INVALID_REQUEST, + header + " must contain 1-128 safe correlation characters"); + } + } + + /** + * 공백 문자열을 null로 정규화하고 실제 값은 앞뒤 공백을 제거합니다. + */ + private String trimToNull(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..9ee27cf --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,17 @@ +mcp: + discovery: + # 로컬에서도 먼저 Tool Service manifest를 조회하고, 최초 조회 실패 시 아래 bundle의 fallback 파일을 사용한다. + enabled: true + bundles: + - id: ${MCP_TOOL_BUNDLE_ID:core} + manifest-url: ${MCP_TOOL_MANIFEST_URL:http://localhost:18080/tool-manifest} + base-endpoint: ${MCP_TOOL_BASE_ENDPOINT:http://localhost:18080/mcp} + name-prefix: ${MCP_TOOL_NAME_PREFIX:core.} + fallback-manifest-file: ${MCP_FALLBACK_MANIFEST_FILE:file:./config/local-core-tools-manifest-sample-v1.json} + enabled: true + redis: + enabled: false +management: + health: + redis: + enabled: false diff --git a/src/main/resources/application-ocp.yml b/src/main/resources/application-ocp.yml new file mode 100644 index 0000000..3ab829f --- /dev/null +++ b/src/main/resources/application-ocp.yml @@ -0,0 +1,13 @@ +mcp: + identity: ${MCP_IDENTITY} + discovery: + enabled: true + redis: + enabled: true + +management: + server: + port: ${MANAGEMENT_SERVER_PORT:9090} + health: + redis: + enabled: false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..7389e3e --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,91 @@ +spring: + application: + name: ax-hub-mcp-server + profiles: + active: local + threads: + virtual: + enabled: true + lifecycle: + # Drain in-flight requests before the pod dies. A tools/call can run for one Tool timeout + # (max 30s) plus response write, so the 30s default is too short and would kill requests + # whose Tool already executed. Must stay BELOW terminationGracePeriodSeconds (45s). + timeout-per-shutdown-phase: 40s + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + # Redis is a shared cache, never the source of truth. A slow Redis must not slow the + # background refresh, so these timeouts are deliberately far shorter than the Tool timeouts. + connect-timeout: 200ms + timeout: 200ms + +server: + port: ${SERVER_PORT:8080} + shutdown: graceful + +management: + endpoints: + web: + exposure: + include: health,info,toolBundles + endpoint: + health: + probes: + enabled: true + group: + # Accept traffic only after the first discovery attempt and a usable in-memory snapshot. + # A last-good memory/Redis snapshot remains usable when the Tool Service is temporarily unavailable. + readiness: + include: readinessState,toolCatalog + +mcp: + # Identifies this MCP deployment. Used to namespace the shared Redis cache so that + # several MCP servers can share one Redis without overwriting each other. + identity: ${MCP_IDENTITY:local-mcp} + # 각 컨테이너가 직접 처리하는 공개 MCP path. OpenShift Route는 이 값을 rewrite하지 않는다. + endpoint-path: ${MCP_ENDPOINT_PATH:/mcp} + server: + name: shl-axhub-mcp-server + title: SHL AX HUB MCP Server + version: 1.0.0 + protocol: + supported-versions: + - "2025-06-18" + preferred-version: "2025-06-18" + registry: + # local profile uses this file instead of opening a separate Registry HTTP port. + local-tool-file: ${MCP_LOCAL_TOOL_REGISTRY_FILE:file:./config/local-core-tools-manifest-sample-v1.json} + refresh-interval-seconds: 30 + refresh-jitter-seconds: 5 + tool-client: + connect-timeout-millis: 1000 + read-timeout-millis: 5000 + # One Agent Builder -> MCP request budget. Agent Builder drops the connection at 300s, + # so MCP must give up FIRST or its answer arrives after nobody is listening. + # 270s leaves a 30s margin to serialize and write the timeout response. + request-deadline-millis: 270000 + forward-authorization: false + redis: + enabled: true + key-prefix: axhub:mcp:tools + discovery: + # local=false uses the local JSON fixture; non-local deployments must enable Tool Service manifest pull. + enabled: ${MCP_DISCOVERY_ENABLED:false} + connect-timeout-millis: 1000 + read-timeout-millis: 3000 + max-tools-per-bundle: 100 + max-tools-total: 200 + max-manifest-bytes: 1048576 + # Upper bound applied to the timeout a manifest declares, so one Tool cannot consume the whole request budget. + max-tool-timeout-millis: 30000 + # Declared per deployment. baseEndpoint is the execution address and is owned by this file only: + # nothing a Tool Service returns can change where MCP sends the call. + bundles: [] + trace: + enabled: true + # Rejects oversized MCP request bodies before controller processing. + max-body-bytes: 1048576 + +logging: + config: classpath:logback-spring.xml diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..ca10973 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,15 @@ + + + + + + + ${CONSOLE_LOG_PATTERN} + UTF-8 + + + + + + diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/McpServerApplicationTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/McpServerApplicationTest.java new file mode 100644 index 0000000..992b091 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/McpServerApplicationTest.java @@ -0,0 +1,18 @@ +package io.shinhanlife.dap.biz.mcp; + +import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryClient; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +class McpServerApplicationTest { + + @MockitoBean + private ToolRegistryClient toolRegistryClient; + + @Test + void contextLoadsWithoutRedisOrRegistry() { + // ApplicationReady preload is best-effort; a missing Registry response must not fail startup. + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java b/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java new file mode 100644 index 0000000..50dc0ef --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java @@ -0,0 +1,74 @@ +package io.shinhanlife.dap.biz.mcp; + +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; +import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; + +import java.time.Instant; +import java.util.List; + +import tools.jackson.databind.ObjectMapper; + +public final class TestFixtures { + + public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private TestFixtures() { + } + + public static McpProperties properties(boolean redisEnabled, boolean forwardAuthorization) { + return properties(redisEnabled, forwardAuthorization, List.of()); + } + + public static McpProperties properties( + boolean redisEnabled, boolean forwardAuthorization, List bundles) { + return new McpProperties( + "mcp-test", + "/mcp", + new McpProperties.Server("shl-axhub-mcp-server", "SHL AX HUB MCP Server", "1.0.0"), + new McpProperties.Registry( + "file:./config/local-core-tools-manifest-sample-v1.json", 30, 5), + new McpProperties.ToolClient(1_000, 5_000, 300_000, forwardAuthorization), + new McpProperties.Redis(redisEnabled, "test:mcp:tools"), + new McpProperties.Trace(true, 1_048_576), + new McpProperties.Protocol(List.of("2025-06-18"), "2025-06-18"), + new McpProperties.Discovery(!bundles.isEmpty(), 1_000, 3_000, 100, 200, 1_048_576, 30_000), + bundles); + } + + public static McpProperties.Bundle bundle( + String id, String manifestUrl, String baseEndpoint, String namePrefix) { + return new McpProperties.Bundle(id, manifestUrl, baseEndpoint, namePrefix, true, null); + } + + public static McpRequestContext context() { + return new McpRequestContext( + "req-1", + "guid-1", + "session-1", + "ENC(employee-1)", + "ENC(virtual-1)", + "Bearer test-token", + Instant.parse("2030-01-01T00:00:00Z")); + } + + public static ToolMetadata tool(String endpoint) { + try { + return new ToolMetadata( + "customer.search", + "1.0.0", + "Search customer information", + endpoint, + OBJECT_MAPPER.readTree( + """ + {"type":"object","properties":{"customerNo":{"type":"string"}}, + "required":["customerNo"]} + """), + 3_000, + true, + null); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java new file mode 100644 index 0000000..a196a56 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java @@ -0,0 +1,98 @@ +package io.shinhanlife.dap.biz.mcp.config; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.bundle; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * bundle 설정이 라우팅을 모호하게 만들지 않는지 기동 시점에 걸러내는 검증 규칙을 확인하는 테스트입니다. 이 규칙들이 없으면 잘못된 설정이 기동에는 성공하고 운영 중 엉뚱한 Tool 라우팅으로 나타납니다. + */ +class McpBundleConfigurationTest { + + @Test + void rejectsDuplicateBundleIds() { + McpProperties properties = + properties( + false, + false, + List.of( + bundle("same", "http://a/manifest", "http://a/mcp", "a."), + bundle("same", "http://b/manifest", "http://b/mcp", "b."))); + + assertThat(properties.isBundleRoutingUnambiguous()).isFalse(); + } + + @Test + void rejectsANamePrefixThatIsAPrefixOfAnother() { + // "a."와 "a.b."가 동시에 있으면 "a.b.search"가 어느 bundle 소속인지 확정되지 않는다. + McpProperties properties = + properties( + false, + false, + List.of( + bundle("outer", "http://a/manifest", "http://a/mcp", "a."), + bundle("inner", "http://b/manifest", "http://b/mcp", "a.b."))); + + assertThat(properties.isBundleRoutingUnambiguous()).isFalse(); + } + + @Test + void acceptsDisjointPrefixes() { + McpProperties properties = + properties( + false, + false, + List.of( + bundle("alpha", "http://a/manifest", "http://a/mcp", "alpha."), + bundle("beta", "http://b/manifest", "http://b/mcp", "beta."))); + + assertThat(properties.isBundleRoutingUnambiguous()).isTrue(); + assertThat(properties.isDiscoveryTargetDeclared()).isTrue(); + } + + @Test + void rejectsDiscoveryWithoutAnyBundle() { + McpProperties properties = + new McpProperties( + "mcp-test", + "/mcp", + null, + null, + null, + null, + null, + null, + new McpProperties.Discovery(true, 1_000, 3_000, 100, 200, 1_048_576, 30_000), + List.of()); + + assertThat(properties.isDiscoveryTargetDeclared()).isFalse(); + } + + @Test + void rejectsDiscoveryWhenEveryDeclaredBundleIsDisabled() { + McpProperties.Bundle disabled = + new McpProperties.Bundle( + "disabled", + "http://tool/manifest", + "http://tool/mcp", + "disabled.", + false, + null); + McpProperties properties = properties(false, false, List.of(disabled)); + + assertThat(properties.isDiscoveryTargetDeclared()).isFalse(); + } + + @Test + void treatsAMissingBundleListAsEmpty() { + McpProperties properties = + new McpProperties("mcp-test", "/mcp", null, null, null, null, null, null, null, null); + + assertThat(properties.bundles()).isEmpty(); + assertThat(properties.enabledBundles()).isEmpty(); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/contract/AgentBuilderContractExampleTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/contract/AgentBuilderContractExampleTest.java new file mode 100644 index 0000000..3b21b6a --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/contract/AgentBuilderContractExampleTest.java @@ -0,0 +1,223 @@ +package io.shinhanlife.dap.biz.mcp.contract; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.context; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator; +import io.shinhanlife.dap.biz.mcp.execute.ToolArgumentValidator; +import io.shinhanlife.dap.biz.mcp.execute.ToolCall; +import io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse; +import io.shinhanlife.dap.biz.mcp.method.InitializeHandler; +import io.shinhanlife.dap.biz.mcp.method.ToolsCallHandler; +import io.shinhanlife.dap.biz.mcp.method.ToolsListHandler; +import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; +import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; + +/** + * `docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/`의 공개 계약 예제를 실제 handler 출력과 대조하는 golden 계약 테스트입니다. 예제 JSON을 테스트가 직접 읽으므로 문서와 코드가 조용히 어긋나면 실패합니다. + * 응답 모양을 바꾸려면 예제 파일과 구현을 함께 바꿔야 합니다. + */ +class AgentBuilderContractExampleTest { + + private static final Path EXAMPLES = + Path.of("docs", "contracts", "agent-builder-mcp", "examples", "agentbuilder-v0.3"); + + /** + * 계약 예제 파일을 읽어 JSON으로 반환하고, 파일이 없으면 원인을 드러내며 실패합니다. + */ + private static JsonNode example(String fileName) throws IOException { + Path file = EXAMPLES.resolve(fileName); + assertThat(Files.exists(file)) + .withFailMessage("계약 예제를 찾을 수 없습니다: %s (작업 디렉터리=%s)", file, Path.of("").toAbsolutePath()) + .isTrue(); + return OBJECT_MAPPER.readTree(Files.readString(file, StandardCharsets.UTF_8)); + } + + @Test + void initializeResponseMatchesThePublishedExample() throws Exception { + JsonNode golden = example("initialize-response.json"); + JsonRpcRequest request = + new JsonRpcRequest("initialize", OBJECT_MAPPER.createObjectNode(), golden.get("id")); + + JsonRpcResponse response = + new InitializeHandler(properties(false, false)).handle(request, context()); + + JsonNode actual = OBJECT_MAPPER.valueToTree(response); + assertThat(actual).isEqualTo(golden); + } + + @Test + void toolsListResponseMatchesThePublishedExample() throws Exception { + JsonNode golden = example("tools-list-response.json"); + // publicDefinition을 채운다. 두 원천(LocalFileToolRegistryClient, ToolBundleDiscovery)이 모두 + // 이 값을 채우므로, null로 두면 실제로는 쓰이지 않는 fallback 분기만 검증하게 된다. + List registryTools = new ArrayList<>(); + for (JsonNode tool : golden.path("result").path("tools")) { + registryTools.add( + new ToolMetadata( + tool.path("name").asString(), + "1.0.0", + tool.path("description").asString(), + "https://tool.example/mcp", + tool.get("inputSchema"), + 3_000, + true, + tool)); + } + ToolRegistryService registryService = mock(ToolRegistryService.class); + when(registryService.listTools()).thenReturn(registryTools); + JsonRpcRequest request = + new JsonRpcRequest("tools/list", OBJECT_MAPPER.createObjectNode(), golden.get("id")); + + JsonRpcResponse response = + new ToolsListHandler(registryService, OBJECT_MAPPER).handle(request, context()); + + // 내부 endpoint/version은 공개 응답에 나타나지 않아야 한다. + JsonNode actual = OBJECT_MAPPER.valueToTree(response); + assertThat(actual).isEqualTo(golden); + } + + /** + * publicDefinition에 실행용 {@code _meta}가 섞여 있어도 공개 응답에는 나가지 않아야 합니다. 두 원천 모두 {@code _meta}를 제거해서 넘기지만, 그 제거가 사라져도 이 경로가 막아야 하므로 handler 쪽에서 확인합니다. + */ + @Test + void toolsListNeverLeaksExecutionMetadata() throws Exception { + JsonNode golden = example("tools-list-response.json"); + JsonNode first = golden.path("result").path("tools").get(0); + ObjectNode leaky = ((ObjectNode) first).deepCopy(); + leaky.set( + "_meta", + OBJECT_MAPPER.readTree( + "{\"endpoint\":\"https://internal.example/mcp\",\"timeoutMillis\":3000}")); + + ToolRegistryService registryService = mock(ToolRegistryService.class); + when(registryService.listTools()) + .thenReturn( + List.of( + new ToolMetadata( + first.path("name").asString(), + "1.0.0", + first.path("description").asString(), + "https://tool.example/mcp", + first.get("inputSchema"), + 3_000, + true, + leaky))); + + JsonRpcResponse response = + new ToolsListHandler(registryService, OBJECT_MAPPER) + .handle( + new JsonRpcRequest( + "tools/list", OBJECT_MAPPER.createObjectNode(), golden.get("id")), + context()); + + String serialized = OBJECT_MAPPER.writeValueAsString(response); + assertThat(serialized).doesNotContain("internal.example").doesNotContain("timeoutMillis"); + } + + @Test + void toolsCallSuccessResponseMatchesThePublishedExample() throws Exception { + JsonNode requestExample = example("tools-call-request.json"); + JsonNode golden = example("tools-call-success-response.json"); + JsonNode goldenContent = golden.path("result").path("content").get(0); + + ToolExecutionService service = mock(ToolExecutionService.class); + when(service.execute(any(), any())) + .thenReturn( + new ToolExecutionService.Result( + OBJECT_MAPPER.getNodeFactory().stringNode(goldenContent.path("text").asString()), + goldenContent.path("_meta").path("searchTime").asDouble())); + JsonRpcRequest request = + new JsonRpcRequest( + requestExample.path("method").asString(), + requestExample.get("params"), + requestExample.get("id")); + + JsonRpcResponse response = new ToolsCallHandler(service).handle(request, context()); + + JsonNode actual = OBJECT_MAPPER.valueToTree(response); + assertThat(actual).isEqualTo(golden); + } + + @Test + void toolsCallExecutionErrorResponseMatchesThePublishedExample() throws Exception { + JsonNode golden = example("tools-call-execution-error-response.json"); + String goldenText = golden.path("result").path("content").get(0).path("text").asString(); + + ToolExecutionService service = mock(ToolExecutionService.class); + when(service.execute(any(), any())) + .thenThrow(new JsonRpcException(JsonRpcErrorCode.TOOL_TIMEOUT, goldenText)); + JsonRpcRequest request = + new JsonRpcRequest( + "tools/call", + OBJECT_MAPPER.readTree("{\"name\":\"customer.search\",\"arguments\":{}}"), + golden.get("id")); + + JsonRpcResponse response = new ToolsCallHandler(service).handle(request, context()); + + // Tool 실행 실패는 최상위 JSON-RPC error가 아니라 isError=true result로 나가야 한다. + assertThat(response.error()).isNull(); + JsonNode actual = OBJECT_MAPPER.valueToTree(response); + assertThat(actual).isEqualTo(golden); + } + + @Test + void invalidParamsErrorCodeAndMessageMatchThePublishedExample() throws Exception { + JsonNode golden = example("tools-call-invalid-params-response.json"); + ToolArgumentValidator validator = + new ToolArgumentValidator(OBJECT_MAPPER, new DefaultJsonSchemaValidator()); + ToolCall call = new ToolCall("processing", OBJECT_MAPPER.readTree("{}")); + ToolMetadata metadata = + new ToolMetadata( + "processing", + "1.0.0", + "Processing", + "https://tool.example/mcp", + OBJECT_MAPPER.readTree( + """ + {"type":"object","properties":{"query":{"type":"string"}},"required":["query"]} + """), + 3_000, + true, + null); + + JsonRpcException thrown = null; + try { + validator.validate(call, metadata); + } catch (JsonRpcException exception) { + thrown = exception; + } + assertThat(thrown).isNotNull(); + + JsonRpcResponse response = + JsonRpcResponse.failure(golden.get("id"), thrown.errorCode(), thrown.errorData()); + JsonNode actual = OBJECT_MAPPER.valueToTree(response); + + // 예제는 진단용 `error.data`(traceId/details)를 생략한 축약형이므로 code/message만 대조한다. + assertThat(actual.path("jsonrpc")).isEqualTo(golden.path("jsonrpc")); + assertThat(actual.path("id")).isEqualTo(golden.path("id")); + assertThat(actual.path("error").path("code")).isEqualTo(golden.path("error").path("code")); + assertThat(actual.path("error").path("message")) + .isEqualTo(golden.path("error").path("message")); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/contract/ToolBundleContractExampleTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/contract/ToolBundleContractExampleTest.java new file mode 100644 index 0000000..039be80 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/contract/ToolBundleContractExampleTest.java @@ -0,0 +1,146 @@ +package io.shinhanlife.dap.biz.mcp.contract; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.bundle; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; +import static org.assertj.core.api.Assertions.assertThat; + +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery; +import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus; +import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; + +import java.lang.reflect.RecordComponent; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JsonNode; + +/** + * 계약 문서의 bundle 예제 JSON을 직접 읽어 구현이 그 계약을 그대로 만족하는지 검증하는 계약 테스트입니다. 문서와 코드가 각자 표류하는 것을 막는 것이 목적이므로, 예제 파일을 고치면 이 테스트가 함께 깨져야 합니다. 조회 대상은 예제 매니페스트를 그대로 돌려주는 + * MockWebServer이며 실제 Tool Service를 호출하지 않습니다. + */ +class ToolBundleContractExampleTest { + + private static final Path EXAMPLES = + Path.of("docs/contracts/tool-service-mcp/examples/bundle-v0.2"); + + private MockWebServer server; + + /** + * 예제 매니페스트를 응답할 조회 대상 서버를 띄웁니다. + */ + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + } + + /** + * 조회 대상 서버를 정리합니다. + */ + @AfterEach + void tearDown() throws Exception { + server.shutdown(); + } + + @Test + void discoversTheContractManifestExampleExactlyAsDocumented() throws Exception { + String manifest = Files.readString(EXAMPLES.resolve("manifest-response.json")); + server.enqueue( + new MockResponse().setHeader("Content-Type", "application/json").setBody(manifest)); + McpProperties properties = + properties( + false, + false, + List.of( + bundle( + "insurance-processing", + server.url("/tool-manifest").toString(), + "http://tool-processing.ax-hub.svc.cluster.local:8080/mcp", + "processing."))); + + List tools = discovery(properties).discoverAll().getFirst().tools(); + + assertThat(tools) + .extracting(ToolMetadata::name) + .containsExactly( + "processing.contract.inquiry", "processing.payment.history", "processing.notice.send"); + // 실행 주소는 설정에서만 온다. 매니페스트에는 endpoint가 없고 있어도 무시한다. + assertThat(tools) + .allSatisfy( + tool -> + assertThat(tool.endpoint()) + .isEqualTo("http://tool-processing.ax-hub.svc.cluster.local:8080/mcp")); + // _meta는 tools/list 공개본에 노출하지 않는다. + assertThat(tools) + .allSatisfy(tool -> assertThat(tool.publicDefinition().has("_meta")).isFalse()); + assertThat(tools.getFirst().version()).isEqualTo("1.2.0"); + // enabled=false로 선언된 Tool은 조회는 되지만 ToolRegistryService가 목록에서 제외한다. + assertThat(tools.stream().filter(ToolMetadata::enabled)).hasSize(2); + } + + /** + * 운영 매니페스트 예제가 {@code outputSchema}를 선언하지 않는지 확인합니다. MCP 2025-06-18에서 {@code outputSchema}를 선언한 서버는 그에 맞는 {@code structuredContent}를 제공해야 하는데, 현재 + * {@code tools/call}은 {@code content[0].text}만 반환합니다. 예제가 이 규칙을 어기면 Tool 개발자가 예제를 그대로 베껴 표준 위반 매니페스트를 만들게 되므로 계약(§5)을 테스트로 고정합니다. + */ + @Test + void theManifestExampleDeclaresNoOutputSchema() throws Exception { + JsonNode manifest = + OBJECT_MAPPER.readTree(Files.readString(EXAMPLES.resolve("manifest-response.json"))); + + assertThat(manifest.path("tools")) + .allSatisfy( + tool -> + assertThat(tool.has("outputSchema")) + .withFailMessage( + "운영 매니페스트 예제는 outputSchema를 선언하지 않는다 (v0.2 §5): %s", + tool.path("name").asString()) + .isFalse()); + } + + @Test + void operationalStatusExampleMatchesTheImplementedResponseShape() throws Exception { + JsonNode example = + OBJECT_MAPPER.readTree(Files.readString(EXAMPLES.resolve("bundle-status-response.json"))); + Set documented = + OBJECT_MAPPER + .convertValue( + example.path("bundles").get(0), new TypeReference>() { + }) + .keySet(); + List implemented = + Arrays.stream(BundleStatus.class.getRecordComponents()) + .map(RecordComponent::getName) + .toList(); + + // 문서 예제와 구현 응답의 field가 어긋나면 운영자가 없는 field를 보고 대시보드를 만들게 된다. + assertThat(documented).containsExactlyInAnyOrderElementsOf(implemented); + assertThat(example.path("bundles")) + .anySatisfy(node -> assertThat(node.path("status").asString()).isEqualTo("disabled")); + } + + /** + * 예제 조회에 사용할 discovery 구성요소를 만듭니다. + */ + private ToolBundleDiscovery discovery(McpProperties properties) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofMillis(properties.discovery().connectTimeoutMillis())); + factory.setReadTimeout(Duration.ofMillis(properties.discovery().readTimeoutMillis())); + return new ToolBundleDiscovery( + RestClient.builder().requestFactory(factory).build(), OBJECT_MAPPER, properties); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/deploy/HelmDeploymentContractTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/deploy/HelmDeploymentContractTest.java new file mode 100644 index 0000000..9ab9c7c --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/deploy/HelmDeploymentContractTest.java @@ -0,0 +1,338 @@ +package io.shinhanlife.dap.biz.mcp.deploy; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.snakeyaml.engine.v2.api.Load; +import org.snakeyaml.engine.v2.api.LoadSettings; + +/** + * Helm Chart의 배포 토폴로지와 환경별 values를 배포 전에 검증하는 계약 테스트입니다. {@code McpProperties}의 {@code @AssertTrue}는 Pod이 뜬 뒤에야 잘못된 설정을 잡지만, GitOps에서는 그 시점이 이미 배포된 뒤라 + * CrashLoopBackOff로 나타납니다. 같은 규칙을 여기서 먼저 적용해 잘못된 values가 머지되는 것을 막습니다. + * + *

이 테스트가 고정하는 핵심 규칙은 MCP 배포와 Tool Service의 1:1 관계, 공개 path의 유일성, Route와 애플리케이션 endpoint의 동일성입니다. 이 규칙들은 애플리케이션 불변식이 아니라 배포 결정이므로 production 코드가 아니라 + * 배포 정의에서 잠급니다. 파일을 읽기만 하며 애플리케이션 context나 helm 바이너리를 필요로 하지 않습니다. + */ +class HelmDeploymentContractTest { + + private static final Path CHART = Path.of("deploy", "helm", "mcp-server"); + private static final Path VALUES = CHART.resolve("values.yaml"); + + /** + * 환경별 values가 파싱되고 {@code global.env}가 파일 이름과 일치하는지 확인합니다. 이 값이 어긋나면 identity 접미사가 환경과 달라져 서로 다른 환경이 같은 Redis key를 쓰게 됩니다. + */ + @ParameterizedTest + @ValueSource(strings = {"dev", "test", "prod"}) + void environmentValuesDeclareTheMatchingEnvironmentAndPublicHost(String env) throws IOException { + Map values = loadYaml(environmentValues(env)); + Map global = section(values, "global"); + + assertThat(global.get("env")) + .withFailMessage("values-%s.yaml의 global.env가 파일 이름과 다릅니다.", env) + .isEqualTo(env); + assertThat(String.valueOf(global.get("mcpHost"))) + .withFailMessage("values-%s.yaml에 공개 MCP host가 없습니다.", env) + .isNotBlank() + .doesNotContain("null", "http://", "https://", "/"); + assertThat(String.valueOf(section(values, "route").get("sourceAllowlist"))) + .withFailMessage("values-%s.yaml에 Agent Builder source CIDR allowlist가 없습니다.", env) + .isNotBlank() + .doesNotContain("null"); + } + + /** + * 환경별 values가 배포 토폴로지를 소유하지 않는지 확인합니다. 환경 축과 배포 축을 한 파일에 섞으면 배포가 늘어날 때마다 환경 설정이 복제되고, 같은 사실이 여러 파일에 흩어져 결국 서로 어긋납니다. + */ + @ParameterizedTest + @ValueSource(strings = {"dev", "test", "prod"}) + void environmentValuesDoNotOwnTheTopology(String env) throws IOException { + Map values = loadYaml(environmentValues(env)); + + assertThat(values) + .withFailMessage( + "values-%s.yaml이 배포 토폴로지를 갖고 있습니다. deployments는 values.yaml 한 곳에만 둡니다.", env) + .doesNotContainKeys("deployments", "deploymentKey"); + } + + /** + * 모든 배포가 자기가 보는 Tool Service와 가용성 등급을 선언하는지 확인합니다. 주소가 아니라 서비스 이름만 선언해야 template이 namespace를 붙여 조립할 수 있고, values에 URL을 직접 적기 시작하면 오타가 그대로 라우팅 사고가 됩니다. + */ + @Test + void everyDeploymentDeclaresItsToolServiceTierAndPublicPath() throws IOException { + Map values = loadYaml(VALUES); + Map deployments = section(values, "deployments"); + Set knownTiers = section(values, "tiers").keySet(); + + assertThat(deployments) + .withFailMessage("values.yaml에 deployments가 없습니다. 이 목록이 배포 토폴로지의 정본입니다.") + .isNotEmpty(); + + deployments.forEach((key, raw) -> { + Map deployment = asMap(raw); + assertThat(deployment) + .withFailMessage( + "deployments.%s에 name/service/namePrefix/tier/publicPath가 모두 있어야 합니다: %s", + key, deployment) + .containsKeys("name", "service", "namePrefix", "tier", "publicPath"); + assertThat(deployment) + .withFailMessage("deployments.%s가 주소를 직접 적고 있습니다. template이 조립합니다.", key) + .doesNotContainKeys("manifestUrl", "baseEndpoint", "bundles"); + assertThat(String.valueOf(deployment.get("namePrefix"))) + .withFailMessage("deployments.%s의 namePrefix가 비어 있습니다.", key) + .isNotBlank(); + assertThat(String.valueOf(deployment.get("publicPath"))) + .withFailMessage("deployments.%s의 publicPath가 /mcp/<영문 소문자·숫자·하이픈> 형식이 아닙니다.", key) + .matches("/mcp/[a-z0-9-]+"); + assertThat(knownTiers) + .withFailMessage( + "deployments.%s의 tier '%s'가 values.yaml의 tiers에 없습니다.", key, deployment.get("tier")) + .contains(String.valueOf(deployment.get("tier"))); + }); + } + + /** + * 공개 path가 배포마다 유일한지 확인합니다. 같은 host와 path를 두 Route가 공유하면 어느 MCP Service로 전달될지 배포 순서에 따라 달라집니다. + */ + @Test + void deploymentPublicPathsAreUnique() throws IOException { + List paths = + section(loadYaml(VALUES), "deployments").values().stream() + .map(raw -> String.valueOf(asMap(raw).get("publicPath"))) + .toList(); + + assertThat(paths) + .withFailMessage("공개 MCP path가 중복됩니다. 한 path는 한 MCP Deployment만 가리켜야 합니다: %s", paths) + .doesNotHaveDuplicates(); + } + + /** + * 배포 이름이 서로 겹치지 않는지 확인합니다. 이름은 Deployment·Service·ConfigMap·NetworkPolicy의 리소스 이름이 되므로, 같은 namespace에서 겹치면 나중에 설치한 배포가 앞의 것을 덮어씁니다. + */ + @Test + void deploymentResourceNamesAreUnique() throws IOException { + List names = + section(loadYaml(VALUES), "deployments").values().stream() + .map(raw -> String.valueOf(asMap(raw).get("name"))) + .toList(); + + assertThat(names) + .withFailMessage("배포 이름이 중복됩니다. 같은 namespace에서 리소스가 서로를 덮어씁니다: %s", names) + .doesNotHaveDuplicates(); + } + + /** + * 어떤 {@code namePrefix}도 다른 prefix의 진부분 접두사가 아닌지 확인합니다. {@code a.}와 {@code a.b.}가 함께 있으면 {@code a.b.search}가 어느 Tool Service 것인지 이름만으로는 확정되지 않습니다. + * 서로 다른 MCP에 흩어져 있으면 MCP는 이를 감지할 수 없으므로 여기서 막습니다. + * + *

완전히 같은 prefix는 허용합니다. 같은 업무를 등급으로 나눈 두 배포가 같은 업무 prefix를 + * 공유하는 것은 의도된 구성입니다(ADR-0007). 그 안에서 Tool 이름이 겹치지 않게 하는 것은 Tool Service 책임입니다. + */ + @Test + void noNamePrefixIsAStrictPrefixOfAnother() throws IOException { + List prefixes = + section(loadYaml(VALUES), "deployments").values().stream() + .map(raw -> String.valueOf(asMap(raw).get("namePrefix"))) + .distinct() + .toList(); + + List conflicts = new ArrayList<>(); + for (String outer : prefixes) { + for (String inner : prefixes) { + if (!outer.equals(inner) && inner.startsWith(outer)) { + conflicts.add(outer + " ⊂ " + inner); + } + } + } + + assertThat(conflicts) + .withFailMessage("namePrefix가 다른 prefix의 접두사입니다. Tool 이름의 소속이 확정되지 않습니다: %s", conflicts) + .isEmpty(); + } + + /** + * ConfigMap이 Tool Service를 정확히 하나만 묶는지 확인합니다. 1:1은 ADR-0007의 결정이며 production 코드가 아니라 여기서 잠급니다. bundle 목록을 {@code range}로 돌리기 시작하면 그 순간 M:N으로 되돌아가고, 등급이 다른 + * Tool Service가 한 MCP에 묶여 카탈로그 갱신이 서로를 막게 됩니다. + */ + @Test + void configMapBindsExactlyOneToolService() throws IOException { + String configMap = Files.readString(CHART.resolve("templates/configmap.yaml")); + + List bundleEntries = + configMap.lines().map(String::trim).filter(line -> line.startsWith("- id:")).toList(); + + assertThat(configMap).contains("bundles:"); + assertThat(bundleEntries) + .withFailMessage("ConfigMap이 bundle을 정확히 하나만 만들어야 합니다(ADR-0007): %s", bundleEntries) + .hasSize(1); + assertThat(configMap) + .withFailMessage("ConfigMap이 bundle 목록을 반복 렌더링하고 있습니다. 1:1이 깨졌습니다(ADR-0007).") + .doesNotContain("range"); + } + + /** + * 모든 환경이 사용 중인 등급을 빠짐없이 선언하는지 확인합니다. 환경 values가 등급 하나를 빠뜨리면 values.yaml의 기본값이 조용히 적용되어, dev인데 prod 기준 replica로 뜨거나 그 반대가 됩니다. + */ + @ParameterizedTest + @ValueSource(strings = {"dev", "test", "prod"}) + void everyEnvironmentDeclaresEveryTierInUse(String env) throws IOException { + Set tiersInUse = + section(loadYaml(VALUES), "deployments").values().stream() + .map(raw -> String.valueOf(asMap(raw).get("tier"))) + .collect(Collectors.toSet()); + + Set declared = section(loadYaml(environmentValues(env)), "tiers").keySet(); + + assertThat(declared) + .withFailMessage("values-%s.yaml이 선언하지 않은 등급이 있습니다. 기본값이 조용히 적용됩니다.", env) + .containsAll(tiersInUse); + } + + /** + * test와 prod의 중요 등급이 단일 장애점을 갖지 않도록 설정됐는지 확인합니다. replica가 1이면 rolling update 중 반드시 공백이 생기고, PodDisruptionBudget이 없으면 노드 drain이 마지막 Pod을 내릴 수 있습니다. 노드 분산이 + * 꺼져 있으면 여러 replica가 같은 노드 장애를 공유하므로 세 설정은 함께 유지해야 합니다(ADR-0007). dev는 Pod 1개로 운영하므로 대상이 아닙니다. + */ + @ParameterizedTest + @ValueSource(strings = {"test", "prod"}) + void criticalTierDeclaresAvailabilitySettings(String env) throws IOException { + Map critical = asMap(section(loadYaml(environmentValues(env)), "tiers").get("critical")); + + assertThat((Integer) critical.get("replicas")) + .withFailMessage("%s의 critical 등급 replica가 2 미만입니다. 배포 중 공백이 생깁니다: %s", env, critical) + .isGreaterThanOrEqualTo(2); + assertThat(critical.get("podDisruptionBudget")) + .withFailMessage("%s의 critical 등급에 PodDisruptionBudget이 없습니다.", env) + .isEqualTo(true); + assertThat(critical.get("spreadAcrossNodes")) + .withFailMessage("%s의 critical 등급이 replica를 노드에 분산하지 않습니다.", env) + .isEqualTo(true); + } + + /** + * PodDisruptionBudget template이 존재하고 등급 설정으로 켜지는지 확인합니다. 값만 {@code true}로 두고 template이 없으면 아무 일도 일어나지 않은 채 검사만 통과합니다. + */ + @Test + void podDisruptionBudgetTemplateUsesTheTierSetting() throws IOException { + String pdb = Files.readString(CHART.resolve("templates/poddisruptionbudget.yaml")); + + assertThat(pdb).contains("kind: PodDisruptionBudget").contains("$tier.podDisruptionBudget"); + } + + /** + * 설치 대상 배포에 기본값이 없는지, identity를 values가 직접 정하지 않는지 확인합니다. {@code deploymentKey}에 기본값이 있으면 지정을 빠뜨렸을 때 엉뚱한 배포가 조용히 설치됩니다. identity를 손으로 적으면 dev·test·prod가 같은 + * 값을 갖는 실수가 나고, 그 순간 서로의 Tool snapshot을 덮어씁니다. + */ + @Test + void deploymentKeyAndIdentityAreNotDefaultedInValues() throws IOException { + Map values = loadYaml(VALUES); + + Object deploymentKey = values.get("deploymentKey"); + assertThat(deploymentKey == null || String.valueOf(deploymentKey).isEmpty()) + .withFailMessage("deploymentKey에 기본값 '%s'가 있습니다. 지정을 빠뜨린 설치가 조용히 성공합니다.", deploymentKey) + .isTrue(); + assertThat(section(values, "mcp")) + .withFailMessage("values.yaml이 identity를 직접 정하고 있습니다. helper가 조립해야 합니다.") + .doesNotContainKey("identity"); + } + + /** + * 인증을 하지 않는 전제인 NetworkPolicy가 Chart에서 빠지지 않았는지 확인합니다. ADR-0006의 성립 조건이므로 비활성화 조건 없이 항상 렌더링되어야 합니다. + */ + @Test + void networkPolicyRestrictsBothPortsAndHasNoDisableSwitch() throws IOException { + String policy = Files.readString(CHART.resolve("templates/networkpolicy.yaml")); + + assertThat(policy) + .contains("kind: NetworkPolicy") + .contains("kubernetes.io/metadata.name: {{ .Values.global.agentBuilderNamespace }}") + .contains("policy-group.network.openshift.io/ingress: \"\"") + .contains("kubernetes.io/metadata.name: {{ .Values.global.monitoringNamespace }}") + .contains("port: {{ .Values.ports.http }}") + .contains("port: {{ .Values.ports.management }}"); + // {{ if .Values...enabled }}로 감싸면 values 한 줄로 인가가 사라진다. + assertThat(policy).doesNotContain("{{- if").doesNotContain("{{ if"); + } + + /** + * OpenShift Route가 환경별 공통 host와 배포별 고유 path를 사용해 선택된 MCP Service로 전달하는지 확인합니다. path rewrite는 금지하며 TLS와 route timeout은 공개 HTTP 경계에 둡니다. + */ + @Test + void routeMapsThePublicPathToTheSelectedMcpService() throws IOException { + String route = Files.readString(CHART.resolve("templates/route.yaml")); + + assertThat(route) + .contains("apiVersion: route.openshift.io/v1") + .contains("kind: Route") + .doesNotContain("haproxy.router.openshift.io/rewrite-target") + .contains("haproxy.router.openshift.io/timeout: {{ .Values.route.timeout }}") + .contains("haproxy.router.openshift.io/ip_allowlist: {{ .Values.route.sourceAllowlist | quote }}") + .contains("host: {{ .Values.global.mcpHost | quote }}") + .contains("path: {{ $deployment.publicPath | quote }}") + .contains("kind: Service") + .contains("name: {{ include \"mcp-server.name\" . }}") + .contains("targetPort: http") + .contains("termination: edge") + .contains("insecureEdgeTerminationPolicy: Redirect"); + + assertThat(Files.readString(CHART.resolve("templates/configmap.yaml"))) + .contains("endpoint-path: {{ $deployment.publicPath | quote }}"); + } + + /** + * Deployment가 운영 profile과 ConfigMap 우선 적용을 유지하는지, replica를 등급에서 가져오는지 확인합니다. ConfigMap checksum annotation이 빠지면 bundle 설정을 고쳐도 기존 Pod이 옛 설정으로 계속 돕니다. + */ + @Test + void deploymentUsesOperationalProfileAndRollsOnConfigChange() throws IOException { + String deployment = Files.readString(CHART.resolve("templates/deployment.yaml")); + + assertThat(deployment) + .contains("name: SPRING_PROFILES_ACTIVE") + .contains("value: ocp") + .contains("SPRING_CONFIG_ADDITIONAL_LOCATION") + .contains("checksum/config:") + .contains("replicas: {{ $tier.replicas }}"); + } + + /** + * 환경별 values 파일 경로를 만듭니다. + */ + private Path environmentValues(String env) { + return CHART.resolve("values-" + env + ".yaml"); + } + + /** + * values 파일을 YAML로 읽습니다. + */ + @SuppressWarnings("unchecked") + private Map loadYaml(Path path) throws IOException { + Load load = new Load(LoadSettings.builder().build()); + Object loaded = load.loadFromString(Files.readString(path)); + return loaded == null ? new LinkedHashMap<>() : (Map) loaded; + } + + /** + * 최상위 절을 꺼내되 없으면 빈 map을 돌려줘 호출부가 null을 검사하지 않게 합니다. + */ + private Map section(Map values, String name) { + return asMap(values.get(name)); + } + + /** + * YAML이 map으로 읽힌 값을 꺼내되 없으면 빈 map을 돌려줍니다. + */ + @SuppressWarnings("unchecked") + private Map asMap(Object value) { + return value == null ? new LinkedHashMap<>() : (Map) value; + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/docs/ArchitectureDocumentContractTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/docs/ArchitectureDocumentContractTest.java new file mode 100644 index 0000000..3b6dbb6 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/docs/ArchitectureDocumentContractTest.java @@ -0,0 +1,74 @@ +package io.shinhanlife.dap.biz.mcp.docs; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +/** + * {@code docs/architecture.md}의 클래스 책임 표가 실제 소스와 어긋나지 않는지 확인하는 문서 계약 테스트입니다. 이 표는 코드 구조를 문서에 복제한 것이라 class를 rename하거나 package를 옮기면 조용히 낡습니다. 실제로 패키지 재구성 한 번에 네 + * 개의 이름이 죽은 적이 있어, 사람의 주의력 대신 테스트로 고정합니다. 소스를 읽기만 하며 애플리케이션 context를 띄우지 않습니다. + */ +class ArchitectureDocumentContractTest { + + private static final Path ARCHITECTURE = Path.of("docs", "architecture.md"); + private static final Path MAIN_PACKAGE = + Path.of("src", "main", "java", "io", "shinhanlife", "dap", "biz", "mcp"); + /** + * 표의 첫 두 칸에 백틱으로 감싼 타입 이름과 패키지 경로가 있는 행만 뽑는다. + */ + private static final Pattern TABLE_ROW = + Pattern.compile("^\\| `([A-Z][A-Za-z0-9]*)` \\| `([a-z0-9/]+)` \\|"); + + /** + * 클래스 표에 적힌 모든 타입이 {@code src/main/java}에 실제로 존재하는지 확인합니다. 존재하지 않는 이름이 있으면 rename 후 문서를 갱신하지 않은 것이므로, 어떤 이름인지 함께 알려 줍니다. + */ + @Test + void everyDocumentedClassPathStillExists() throws IOException { + List documented = documentedTypes(); + + // 표 자체가 사라지면 이 테스트가 조용히 통과해 버리므로 최소 개수를 함께 고정한다. + assertThat(documented) + .withFailMessage("architecture.md의 클래스 책임 표를 찾지 못했습니다. 표 형식이 바뀌었는지 확인하세요.") + .hasSizeGreaterThan(10); + + List missing = documented.stream().filter(type -> !sourceExists(type)).toList(); + + assertThat(missing) + .withFailMessage( + "architecture.md에 적힌 package와 class 경로에 소스가 없는 타입: %s%n" + + "class를 rename하거나 package를 옮겼다면 문서의 표도 같은 변경에서 고쳐야 합니다.", + missing) + .isEmpty(); + } + + /** + * 클래스 책임 표에서 타입 이름과 패키지 경로를 순서대로 모읍니다. + */ + private List documentedTypes() throws IOException { + try (Stream lines = Files.lines(ARCHITECTURE)) { + return lines.map(TABLE_ROW::matcher) + .filter(Matcher::find) + .map(matcher -> new DocumentedType(matcher.group(1), matcher.group(2))) + .distinct() + .toList(); + } + } + + /** + * 문서에 적힌 패키지와 타입 이름이 가리키는 main 소스 파일이 정확히 존재하는지 확인합니다. + */ + private boolean sourceExists(DocumentedType type) { + return Files.isRegularFile(MAIN_PACKAGE.resolve(type.packagePath()).resolve(type.name() + ".java")); + } + + private record DocumentedType(String name, String packagePath) { + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/docs/CodeStyleContractTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/docs/CodeStyleContractTest.java new file mode 100644 index 0000000..63e4d7f --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/docs/CodeStyleContractTest.java @@ -0,0 +1,215 @@ +package io.shinhanlife.dap.biz.mcp.docs; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +/** + * Java 소스의 기계적 서식 규칙을 빌드에서 강제하는 계약 테스트입니다. 이전에는 Spotless Gradle 플러그인이 같은 검사를 했지만, 그 플러그인은 빌드를 읽는 시점에 외부 저장소에서 내려받아야 해서 폐쇄망에서는 검사 하나 때문에 빌드 전체가 시작되지 못합니다. 규칙을 + * 여기로 옮겨 외부 의존성 없이 같은 것을 지킵니다. + * + *

여기서 보는 것은 도구 없이도 판정할 수 있는 규칙뿐입니다. 들여쓰기 폭과 줄바꿈 위치는 IntelliJ 코드 스타일({@code .idea/codeStyles/Project.xml})이 소유하며 이 테스트가 판정하지 않습니다. 소스를 읽기만 하며 + * 애플리케이션 context를 띄우지 않습니다. + */ +class CodeStyleContractTest { + + private static final List SOURCE_ROOTS = + List.of(Path.of("src", "main", "java"), Path.of("src", "test", "java")); + /** + * {@code import a.b.C;}와 {@code import static a.b.C.d;}에서 마지막 이름만 뽑는다. + */ + private static final Pattern IMPORT = Pattern.compile("^import (?:static )?[\\w.]*?(\\w+);"); + + /** + * 모든 Java 소스가 LF 줄바꿈만 쓰는지 확인합니다. CRLF가 섞이면 Linux 컨테이너에서 문제가 되고, 한 번 섞인 파일은 이후 모든 변경의 diff가 파일 전체로 부풀어 실제 변경을 가립니다. + */ + @Test + void everySourceUsesUnixLineEndings() throws IOException { + List broken = violations(source -> source.raw().contains("\r\n")); + + assertThat(broken).withFailMessage("CRLF 줄바꿈이 있는 파일: %s", broken).isEmpty(); + } + + /** + * 들여쓰기에 탭을 쓰지 않는지 확인합니다. 탭과 공백이 섞이면 보는 도구마다 정렬이 달라집니다. + */ + @Test + void noSourceContainsTabCharacters() throws IOException { + List broken = violations(source -> source.raw().contains("\t")); + + assertThat(broken).withFailMessage("탭 문자가 있는 파일: %s", broken).isEmpty(); + } + + /** + * 줄 끝에 눈에 보이지 않는 공백이 남아 있지 않은지 확인합니다. 화면에 드러나지 않아 사람이 리뷰로 잡을 수 없고, 의미 없는 diff만 만듭니다. + */ + @Test + void noLineEndsWithWhitespace() throws IOException { + List broken = + violations( + source -> + source.lines().stream() + .anyMatch(line -> !line.equals(line.stripTrailing()))); + + assertThat(broken).withFailMessage("줄 끝에 공백이 있는 파일: %s", broken).isEmpty(); + } + + /** + * 파일이 개행 하나로 끝나는지 확인합니다. 개행이 없으면 마지막 줄을 고칠 때 diff가 두 줄로 보이고, 여러 개면 의미 없는 빈 줄이 쌓입니다. + */ + @Test + void everySourceEndsWithExactlyOneNewline() throws IOException { + List broken = + violations(source -> !source.raw().endsWith("\n") || source.raw().endsWith("\n\n")); + + assertThat(broken).withFailMessage("파일 끝 개행이 정확히 하나가 아닌 파일: %s", broken).isEmpty(); + } + + /** + * 쓰지 않는 {@code import}가 남아 있지 않은지 확인합니다. 클래스를 옮기거나 지운 뒤 정리하지 않으면 남으며, 실제로는 없는 의존 관계가 있는 것처럼 보이게 합니다. + * + *

판정은 그 이름이 import 문 바깥 어디에든 나타나는지로 합니다. Javadoc의 {@code @link}도 사용으로 봅니다. 실제로 쓰는 import를 지우라고 하는 오탐이 없어야 하기 때문입니다. + */ + @Test + void noSourceKeepsAnUnusedImport() throws IOException { + List unused = new ArrayList<>(); + for (JavaSource source : sources()) { + String body = + String.join( + "\n", + source.lines().stream().filter(line -> !line.startsWith("import ")).toList()); + for (String line : source.lines()) { + Matcher matcher = IMPORT.matcher(line); + if (matcher.find() && !containsWord(body, matcher.group(1))) { + unused.add(source.path() + " -> " + matcher.group(1)); + } + } + } + + assertThat(unused).withFailMessage("사용하지 않는 import: %s", unused).isEmpty(); + } + + /** + * {@code import}가 static 먼저, 그다음 알파벳 순으로 놓였는지 확인합니다. 순서가 제각각이면 같은 import를 두 사람이 다른 자리에 넣어 실제 변경과 무관한 diff가 생깁니다. + * + *

비교는 세미콜론을 뗀 경로로 합니다. {@code A;}와 {@code A.B;}를 문자열 그대로 비교하면 {@code ';'}(0x3B)가 {@code '.'}(0x2E)보다 커서 중첩 타입이 바깥 타입보다 앞서야 한다고 잘못 + * 판정합니다. + * + *

그룹 사이 빈 줄은 검사하지 않습니다. 저장소 전체를 세어 보면 빈 줄을 넣은 경계와 넣지 않은 경계가 섞여 있어 지킬 관례가 존재하지 않습니다. 없는 규칙을 만들어 기존 파일을 무더기로 고치는 것보다, 실재하는 규칙만 + * 잠그는 편이 낫습니다. + */ + @Test + void importsAreOrderedStaticFirstThenAlphabetically() throws IOException { + List broken = new ArrayList<>(); + for (JavaSource source : sources()) { + List statics = new ArrayList<>(); + List regular = new ArrayList<>(); + for (String line : source.lines()) { + if (line.startsWith("import static ")) { + statics.add(line.substring("import static ".length()).replace(";", "")); + } else if (line.startsWith("import ")) { + regular.add(line.substring("import ".length()).replace(";", "")); + } + } + if (!isSorted(statics) || !isSorted(regular)) { + broken.add(source.path()); + } + if (!source.staticImportsComeFirst()) { + broken.add(source.path() + " (static import가 일반 import 뒤에 있음)"); + } + } + + assertThat(broken).withFailMessage("import 순서가 어긋난 파일: %s", broken).isEmpty(); + } + + /** + * 검사 대상 소스가 실제로 수집되는지 확인합니다. 경로가 바뀌어 목록이 비면 위 검사들이 모두 조용히 통과하므로 최소 개수를 함께 고정합니다. + */ + @Test + void theSourceSetIsActuallyScanned() throws IOException { + assertThat(sources()) + .withFailMessage("Java 소스를 찾지 못했습니다. SOURCE_ROOTS 경로가 바뀌었는지 확인하세요.") + .hasSizeGreaterThan(50); + } + + /** + * 규칙을 어긴 파일 경로를 모읍니다. 어떤 파일인지 알려주지 않으면 고칠 수가 없습니다. + */ + private List violations(Predicate broken) throws IOException { + return sources().stream().filter(broken).map(JavaSource::path).toList(); + } + + /** + * 목록이 오름차순인지 확인합니다. 정렬본과 비교하면 어긋난 위치를 따로 추적하지 않아도 됩니다. + */ + private boolean isSorted(List values) { + return values.equals(values.stream().sorted().toList()); + } + + /** + * 이름이 식별자 경계에 맞게 등장하는지 확인합니다. {@code List}를 찾을 때 {@code ArrayList}가 걸리지 않아야 합니다. + */ + private boolean containsWord(String text, String word) { + return Pattern.compile("\\b" + Pattern.quote(word) + "\\b").matcher(text).find(); + } + + /** + * main과 test의 모든 Java 소스를 읽어 옵니다. + */ + private List sources() throws IOException { + List sources = new ArrayList<>(); + for (Path root : SOURCE_ROOTS) { + try (Stream paths = Files.walk(root)) { + for (Path path : paths.filter(path -> path.toString().endsWith(".java")).toList()) { + sources.add( + new JavaSource( + path.toString().replace('\\', '/'), + new String(Files.readAllBytes(path), StandardCharsets.UTF_8))); + } + } + } + return sources; + } + + /** + * 검사 대상 소스 하나의 경로와 원본 내용입니다. 줄바꿈 검사 때문에 줄 단위가 아니라 원본 문자열을 그대로 들고 있어야 합니다. + */ + private record JavaSource(String path, String raw) { + + /** + * 줄 단위 검사를 위해 개행으로만 나눕니다. CR이 남아 있으면 줄 끝 공백 검사에서도 함께 드러납니다. + */ + List lines() { + return List.of(raw.split("\n", -1)); + } + + /** + * 마지막 static import가 첫 일반 import보다 앞에 있는지 확인합니다. 둘 중 한쪽이 없으면 판정할 것이 없으므로 참입니다. + */ + boolean staticImportsComeFirst() { + List lines = lines(); + int lastStatic = -1; + int firstRegular = Integer.MAX_VALUE; + for (int index = 0; index < lines.size(); index++) { + String line = lines.get(index); + if (line.startsWith("import static ")) { + lastStatic = index; + } else if (line.startsWith("import ") && firstRegular == Integer.MAX_VALUE) { + firstRegular = index; + } + } + return lastStatic < firstRegular; + } + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/docs/PackageBoundaryContractTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/docs/PackageBoundaryContractTest.java new file mode 100644 index 0000000..57f402e --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/docs/PackageBoundaryContractTest.java @@ -0,0 +1,112 @@ +package io.shinhanlife.dap.biz.mcp.docs; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +/** + * 패키지 경계를 코드로 고정하는 계약 테스트입니다. MCP는 stdio 등 다른 transport를 가질 수 있는 프로토콜이므로, inbound Servlet 지식이 전송 경계 밖으로 새면 전송 방식이 응용 계층에 굳어져 나중에 떼어낼 수 없게 됩니다. 실제로 재구성 전에는 서블릿 + * 타입이 세 패키지에 흩어져 있었고, 문서만으로는 다시 새는 것을 막지 못합니다. 소스 파일을 읽기만 하며 애플리케이션 context를 띄우지 않습니다. + */ +class PackageBoundaryContractTest { + + private static final Path MAIN_SOURCES = Path.of("src", "main", "java"); + /** + * 전송 경계 안쪽. 이 아래에서만 서블릿 API를 다룰 수 있다. + */ + private static final String TRANSPORT_PACKAGE = "io/shinhanlife/dap/biz/mcp/transport/"; + + /** + * 서블릿 API를 import하는 production 파일이 {@code transport} 패키지 안에만 있는지 확인합니다. 밖에서 발견되면 어떤 파일인지 함께 알려 주고, 옮기거나 서블릿 타입을 걷어내도록 유도합니다. + */ + @Test + void servletApiStaysInsideTheTransportPackage() throws IOException { + List leaks = sourcesImporting("jakarta.servlet").stream() + .filter(path -> !normalize(path).contains(TRANSPORT_PACKAGE)) + .toList(); + + assertThat(leaks) + .withFailMessage( + "jakarta.servlet은 transport 패키지 안에서만 사용한다. 경계 밖에서 발견된 파일: %s%n" + + "HTTP 전용 코드라면 transport/http로 옮기고, 아니라면 서블릿 타입을 파라미터에서 제거하세요.", + leaks) + .isEmpty(); + } + + /** + * 전송 경계 안쪽 코드가 Tool 실행·Registry 내부로 직접 들어가지 않는지 확인합니다. transport는 요청을 받아 method handler에 넘기는 데까지가 책임이며, 실행 상세는 그 뒤 계층이 소유합니다. + */ + @Test + void transportDoesNotReachIntoExecutionOrRegistry() throws IOException { + List violations = sourcesImportingAny(List.of( + "io.shinhanlife.dap.biz.mcp.execute.", + "io.shinhanlife.dap.biz.mcp.registry.")) + .stream() + .filter(path -> normalize(path).contains(TRANSPORT_PACKAGE)) + .toList(); + + assertThat(violations) + .withFailMessage( + "transport는 execute 또는 registry 계층을 직접 호출하지 않는다. method handler를 거쳐야 한다: %s", + violations) + .isEmpty(); + } + + /** + * main 소스에서 주어진 import 접두사 중 하나를 사용하는 파일을 모읍니다. + */ + private List sourcesImportingAny(List importPrefixes) throws IOException { + try (Stream paths = Files.walk(MAIN_SOURCES)) { + return paths.filter(path -> path.toString().endsWith(".java")) + .filter(path -> declaresAnyImport(path, importPrefixes)) + .toList(); + } + } + + /** + * main 소스에서 주어진 import 접두사를 사용하는 파일을 모읍니다. + */ + private List sourcesImporting(String importPrefix) throws IOException { + try (Stream paths = Files.walk(MAIN_SOURCES)) { + return paths.filter(path -> path.toString().endsWith(".java")) + .filter(path -> declaresImport(path, importPrefix)) + .toList(); + } + } + + /** + * 파일이 해당 import 선언을 포함하는지 확인합니다. 주석이나 문자열이 아니라 import 줄만 봅니다. + */ + private boolean declaresImport(Path path, String importPrefix) { + try (Stream lines = Files.lines(path)) { + return lines.anyMatch(line -> line.startsWith("import " + importPrefix)); + } catch (IOException exception) { + throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception); + } + } + + /** + * 파일이 주어진 접두사 중 하나에 해당하는 import 선언을 포함하는지 확인합니다. + */ + private boolean declaresAnyImport(Path path, List importPrefixes) { + try (Stream lines = Files.lines(path)) { + return lines.anyMatch(line -> importPrefixes.stream() + .anyMatch(importPrefix -> line.startsWith("import " + importPrefix))); + } catch (IOException exception) { + throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception); + } + } + + /** + * OS별 경로 구분자를 슬래시로 통일해 패키지 비교가 Windows에서도 동작하게 합니다. + */ + private String normalize(Path path) { + return path.toString().replace('\\', '/'); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidatorTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidatorTest.java new file mode 100644 index 0000000..c092d44 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidatorTest.java @@ -0,0 +1,77 @@ +package io.shinhanlife.dap.biz.mcp.execute; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; +import org.junit.jupiter.api.Test; + +class ToolArgumentValidatorTest { + + private final ToolArgumentValidator validator = + new ToolArgumentValidator(OBJECT_MAPPER, new DefaultJsonSchemaValidator()); + + @Test + void reportsMissingRequiredQueryAsInvalidParams() throws Exception { + ToolCall call = new ToolCall("document.search", OBJECT_MAPPER.readTree("{}")); + ToolMetadata metadata = + new ToolMetadata( + "document.search", + "1.0.0", + "Search documents", + "http://tool.example/search", + OBJECT_MAPPER.readTree( + """ + {"type":"object","properties":{"query":{"type":"string"}},"required":["query"]} + """), + 3_000, + true, + null); + + assertThatThrownBy(() -> validator.validate(call, metadata)) + .isInstanceOfSatisfying( + JsonRpcException.class, + exception -> { + assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INVALID_PARAMS); + assertThat(exception.errorData()).isEqualTo("'query' is required"); + }); + } + + @Test + void appliesJsonSchemaKeywordsBeforeCallingTheTool() throws Exception { + ToolCall call = + new ToolCall( + "document.search", OBJECT_MAPPER.readTree("{\"query\":\"\",\"unexpected\":true}")); + ToolMetadata metadata = + new ToolMetadata( + "document.search", + "1.0.0", + "Search documents", + "http://tool.example/search", + OBJECT_MAPPER.readTree( + """ + { + "type":"object", + "properties":{"query":{"type":"string","minLength":1}}, + "required":["query"], + "additionalProperties":false + } + """), + 3_000, + true, + null); + + assertThatThrownBy(() -> validator.validate(call, metadata)) + .isInstanceOfSatisfying( + JsonRpcException.class, + exception -> { + assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INVALID_PARAMS); + assertThat(exception.errorData()).isEqualTo("arguments do not match inputSchema"); + assertThat(exception.errorData().toString()).doesNotContain("unexpected"); + }); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionServiceTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionServiceTest.java new file mode 100644 index 0000000..ce66262 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionServiceTest.java @@ -0,0 +1,69 @@ +package io.shinhanlife.dap.biz.mcp.execute; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.context; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.tool; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.shinhanlife.dap.biz.mcp.observability.TraceLogger; +import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; +import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService; +import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient; +import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest; +import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse; +import org.junit.jupiter.api.Test; + +class ToolExecutionServiceTest { + + @Test + void executesOnePreparedToolAndReturnsItsResult() throws Exception { + ToolRegistryService registry = mock(ToolRegistryService.class); + ToolArgumentValidator validator = mock(ToolArgumentValidator.class); + ToolRoutingService routing = mock(ToolRoutingService.class); + ToolClient client = mock(ToolClient.class); + ToolCall call = + new ToolCall("customer.search", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}")); + ToolMetadata metadata = tool("http://tool/one"); + ToolRequest request = + new ToolRequest("customer.search", "1.0.0", "http://tool/one", call.arguments(), 3_000); + when(registry.findEnabledTool(call.toolName())).thenReturn(metadata); + when(routing.route(call, metadata)).thenReturn(request); + when(client.execute(request, context())) + .thenReturn(new ToolResponse(200, OBJECT_MAPPER.readTree("{\"order\":1}"))); + ToolExecutionService service = + new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class)); + + var result = service.execute(call, context()); + + assertThat(result.data().path("order").asInt()).isEqualTo(1); + verify(validator).validate(call, metadata); + verify(client).execute(request, context()); + } + + @Test + void validatesArgumentsBeforeCallingTheTool() throws Exception { + ToolRegistryService registry = mock(ToolRegistryService.class); + ToolArgumentValidator validator = mock(ToolArgumentValidator.class); + ToolRoutingService routing = mock(ToolRoutingService.class); + ToolClient client = mock(ToolClient.class); + ToolCall call = new ToolCall("weather", OBJECT_MAPPER.readTree("{\"city\":\"Seoul\"}")); + ToolMetadata metadata = tool("http://tool"); + ToolRequest request = + new ToolRequest( + "weather", metadata.version(), "http://tool/weather", call.arguments(), 3_000); + when(registry.findEnabledTool(call.toolName())).thenReturn(metadata); + when(routing.route(call, metadata)).thenReturn(request); + when(client.execute(request, context())) + .thenReturn(new ToolResponse(200, OBJECT_MAPPER.readTree("{}"))); + ToolExecutionService service = + new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class)); + + service.execute(call, context()); + + verify(validator).validate(call, metadata); + verify(client).execute(request, context()); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingServiceTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingServiceTest.java new file mode 100644 index 0000000..21e404a --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingServiceTest.java @@ -0,0 +1,31 @@ +package io.shinhanlife.dap.biz.mcp.execute; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; +import static org.assertj.core.api.Assertions.assertThat; + +import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; +import org.junit.jupiter.api.Test; + +class ToolRoutingServiceTest { + + @Test + void appendsToolNameForTheSinglePostRoutingContract() throws Exception { + ToolMetadata metadata = + new ToolMetadata( + "weather", + "1.0.0", + "weather", + "https://axhub-tool-other.onrender.com/mcp", + null, + 3_000, + true, + null); + ToolCall call = new ToolCall("weather", OBJECT_MAPPER.readTree("{\"city\":\"Seoul\"}")); + + var request = new ToolRoutingService(properties(false, false)).route(call, metadata); + + assertThat(request.endpoint()).isEqualTo("https://axhub-tool-other.onrender.com/mcp/weather"); + assertThat(request.arguments()).isNotSameAs(call.arguments()); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParserTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParserTest.java new file mode 100644 index 0000000..678b748 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParserTest.java @@ -0,0 +1,49 @@ +package io.shinhanlife.dap.biz.mcp.jsonrpc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +class JsonRpcRequestParserTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private JsonRpcRequestParser parser; + + @BeforeEach + void setUp() { + parser = new JsonRpcRequestParser(); + } + + @Test + void adaptsValidRequest() throws Exception { + JsonRpcRequest request = + parser.parse( + objectMapper.readTree( + """ + {"jsonrpc":"2.0","method":"tools/list","params":{},"id":"req-1"} + """)); + + assertThat(request.method()).isEqualTo("tools/list"); + assertThat(request.id().asString()).isEqualTo("req-1"); + } + + @Test + void rejectsWrongJsonRpcVersionAndKeepsRequestId() throws Exception { + assertThatThrownBy( + () -> + parser.parse( + objectMapper.readTree( + """ + {"jsonrpc":"1.0","method":"tools/list","id":"req-2"} + """))) + .isInstanceOfSatisfying( + JsonRpcException.class, + exception -> { + assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INVALID_REQUEST); + assertThat(exception.requestId().asString()).isEqualTo("req-2"); + }); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandlerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandlerTest.java new file mode 100644 index 0000000..968348c --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandlerTest.java @@ -0,0 +1,44 @@ +package io.shinhanlife.dap.biz.mcp.method; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; +import static org.assertj.core.api.Assertions.assertThat; + +import io.modelcontextprotocol.spec.McpSchema; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.node.JsonNodeFactory; + +class InitializeHandlerTest { + + @Test + void returnsConfiguredInitializeCapabilityAndServerInformation() { + InitializeHandler handler = new InitializeHandler(properties(false, false)); + JsonRpcRequest request = + new JsonRpcRequest( + "initialize", + JsonNodeFactory.instance.objectNode(), + JsonNodeFactory.instance.numberNode(1)); + + var response = handler.handle(request, null); + + assertThat(response.jsonrpc()).isEqualTo("2.0"); + assertThat(response.id().asInt()).isEqualTo(1); + assertThat(response.result()).isInstanceOf(McpSchema.InitializeResult.class); + tools.jackson.databind.JsonNode serialized = + io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER.valueToTree(response.result()); + assertThat(serialized) + .isEqualTo( + io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER.readTree( + """ + { + "protocolVersion":"2025-06-18", + "capabilities":{"tools":{"listChanged":false}}, + "serverInfo":{ + "name":"shl-axhub-mcp-server", + "title":"SHL AX HUB MCP Server", + "version":"1.0.0" + } + } + """)); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandlerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandlerTest.java new file mode 100644 index 0000000..012672a --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandlerTest.java @@ -0,0 +1,24 @@ +package io.shinhanlife.dap.biz.mcp.method; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.context; +import static org.assertj.core.api.Assertions.assertThat; + +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.node.JsonNodeFactory; + +class InitializedNotificationHandlerTest { + + @Test + void acceptsNotificationWithoutPersistingSessionState() { + InitializedNotificationHandler handler = new InitializedNotificationHandler(); + JsonRpcRequest request = + new JsonRpcRequest( + "notifications/initialized", JsonNodeFactory.instance.objectNode(), null); + + var response = handler.handle(request, context()); + + assertThat(response.id()).isNull(); + assertThat(response.result()).isEqualTo(java.util.Map.of()); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandlerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandlerTest.java new file mode 100644 index 0000000..a001d90 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandlerTest.java @@ -0,0 +1,177 @@ +package io.shinhanlife.dap.biz.mcp.method; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.context; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.modelcontextprotocol.spec.McpSchema; +import io.shinhanlife.dap.biz.mcp.execute.ToolCall; +import io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import tools.jackson.databind.JsonNode; + +class ToolsCallHandlerTest { + + @Test + void returnsPlainTextToolResultWithSearchTime() throws Exception { + ToolExecutionService service = mock(ToolExecutionService.class); + JsonRpcRequest request = + new JsonRpcRequest( + "tools/call", + OBJECT_MAPPER.readTree("{\"name\":\"customer.search\",\"arguments\":{}}"), + OBJECT_MAPPER.getNodeFactory().numberNode(3)); + when(service.execute(any(), any())) + .thenReturn(new ToolExecutionService.Result(OBJECT_MAPPER.readTree("\"Hong\""), 976.1)); + + var response = new ToolsCallHandler(service).handle(request, context()); + + ArgumentCaptor call = ArgumentCaptor.forClass(ToolCall.class); + verify(service).execute(call.capture(), any()); + assertThat(call.getValue().toolName()).isEqualTo("customer.search"); + assertThat(response.id()).isEqualTo(request.id()); + assertThat(response.result()).isInstanceOf(McpSchema.CallToolResult.class); + JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result()); + assertThat(serialized) + .isEqualTo( + OBJECT_MAPPER.readTree( + """ + { + "content":[{ + "type":"text", + "text":"Hong", + "_meta":{"searchTime":976.1} + }], + "isError":false + } + """)); + } + + @Test + void serializesJsonToolResponseAsOneEscapedTextValue() throws Exception { + ToolExecutionService service = mock(ToolExecutionService.class); + JsonRpcRequest request = + new JsonRpcRequest( + "tools/call", + OBJECT_MAPPER.readTree("{\"name\":\"users\",\"arguments\":{}}"), + OBJECT_MAPPER.getNodeFactory().numberNode(3)); + String toolResponse = "[{\"id\":1,\"name\":\"Leanne Graham\"}]"; + when(service.execute(any(), any())) + .thenReturn(new ToolExecutionService.Result(OBJECT_MAPPER.readTree(toolResponse), 12.5)); + + var response = new ToolsCallHandler(service).handle(request, context()); + String serialized = OBJECT_MAPPER.writeValueAsString(response); + + assertThat( + OBJECT_MAPPER + .readTree(serialized) + .path("result") + .path("content") + .get(0) + .path("text") + .asString()) + .isEqualTo(toolResponse); + } + + @Test + void returnsToolExecutionFailureAsMcpResult() throws Exception { + ToolExecutionService service = mock(ToolExecutionService.class); + JsonRpcRequest request = + new JsonRpcRequest( + "tools/call", + OBJECT_MAPPER.readTree("{\"name\":\"customer.search\",\"arguments\":{}}"), + OBJECT_MAPPER.getNodeFactory().numberNode(3)); + when(service.execute(any(), any())) + .thenThrow( + new JsonRpcException( + JsonRpcErrorCode.TOOL_TIMEOUT, "customer.search@1.0.0: timed out")); + + var response = new ToolsCallHandler(service).handle(request, context()); + + assertThat(response.error()).isNull(); + assertThat(response.result()).isInstanceOf(McpSchema.CallToolResult.class); + JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result()); + assertThat(serialized) + .isEqualTo( + OBJECT_MAPPER.readTree( + """ + { + "content":[{ + "type":"text", + "text":"customer.search@1.0.0: timed out" + }], + "isError":true + } + """)); + } + + @Test + void propagatesInvalidParamsAsAJsonRpcError() throws Exception { + ToolExecutionService service = mock(ToolExecutionService.class); + JsonRpcRequest request = + new JsonRpcRequest( + "tools/call", + OBJECT_MAPPER.readTree("{\"name\":\"customer.search\",\"arguments\":[]}"), + OBJECT_MAPPER.getNodeFactory().numberNode(3)); + + ToolsCallHandler handler = new ToolsCallHandler(service); + + assertThatThrownBy(() -> handler.handle(request, context())) + .isInstanceOfSatisfying( + JsonRpcException.class, + exception -> { + assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INVALID_PARAMS); + assertThat(exception.errorData()).isEqualTo("params.arguments must be an object"); + assertThat(exception.requestId()).isEqualTo(request.id()); + }); + } + + @Test + void rejectsMissingToolName() throws Exception { + ToolExecutionService service = mock(ToolExecutionService.class); + JsonRpcRequest request = + new JsonRpcRequest( + "tools/call", + OBJECT_MAPPER.readTree("{\"arguments\":{}}"), + OBJECT_MAPPER.getNodeFactory().numberNode(4)); + + assertThatThrownBy(() -> new ToolsCallHandler(service).handle(request, context())) + .isInstanceOfSatisfying( + JsonRpcException.class, + exception -> { + assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INVALID_PARAMS); + assertThat(exception.errorData()).isEqualTo("params.name is required"); + assertThat(exception.requestId()).isEqualTo(request.id()); + }); + } + + @Test + void propagatesServerConfigurationFailureAsAJsonRpcError() throws Exception { + ToolExecutionService service = mock(ToolExecutionService.class); + JsonRpcRequest request = + new JsonRpcRequest( + "tools/call", + OBJECT_MAPPER.readTree("{\"name\":\"customer.search\",\"arguments\":{}}"), + OBJECT_MAPPER.getNodeFactory().numberNode(3)); + when(service.execute(any(), any())) + .thenThrow( + new JsonRpcException( + JsonRpcErrorCode.INTERNAL_ERROR, "Config-based direct Tool routing is disabled")); + + ToolsCallHandler handler = new ToolsCallHandler(service); + + assertThatThrownBy(() -> handler.handle(request, context())) + .isInstanceOfSatisfying( + JsonRpcException.class, + exception -> + assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INTERNAL_ERROR)); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandlerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandlerTest.java new file mode 100644 index 0000000..e639a6b --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandlerTest.java @@ -0,0 +1,114 @@ +package io.shinhanlife.dap.biz.mcp.method; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.context; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.tool; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.modelcontextprotocol.spec.McpSchema; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; +import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +class ToolsListHandlerTest { + + @Test + void exposesOnlyMcpToolFieldsAndHidesInternalRegistryMetadata() throws Exception { + ToolRegistryService registryService = mock(ToolRegistryService.class); + when(registryService.listTools()) + .thenReturn(List.of(tool("http://internal-tool.example/search"))); + ToolsListHandler handler = new ToolsListHandler(registryService, OBJECT_MAPPER); + JsonRpcRequest request = + new JsonRpcRequest("tools/list", OBJECT_MAPPER.readTree("{}"), OBJECT_MAPPER.readTree("2")); + + var response = handler.handle(request, context()); + + assertThat(response.result()).isInstanceOf(McpSchema.ListToolsResult.class); + tools.jackson.databind.JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result()); + assertThat(serialized) + .isEqualTo( + OBJECT_MAPPER.readTree( + """ + { + "tools":[{ + "name":"customer.search", + "description":"Search customer information", + "inputSchema":{ + "type":"object", + "properties":{"customerNo":{"type":"string"}}, + "required":["customerNo"] + } + }] + } + """)); + } + + @Test + void preservesLocalToolsListPublicFieldsAndHidesMetaExecutionFields() throws Exception { + ToolRegistryService registryService = mock(ToolRegistryService.class); + var publicDefinition = + OBJECT_MAPPER.readTree( + """ + {"name":"weather","title":"날씨 조회","description":"weather", + "inputSchema":{"type":"object"},"outputSchema":{"type":"object"}, + "annotations":{"readOnlyHint":true}} + """); + var metadata = + new io.shinhanlife.dap.biz.mcp.registry.ToolMetadata( + "weather", + "1.0.0", + "weather", + "https://tool.example/mcp", + publicDefinition.path("inputSchema"), + 3_000, + true, + publicDefinition); + when(registryService.listTools()).thenReturn(List.of(metadata)); + ToolsListHandler handler = new ToolsListHandler(registryService, OBJECT_MAPPER); + JsonRpcRequest request = + new JsonRpcRequest("tools/list", OBJECT_MAPPER.readTree("{}"), OBJECT_MAPPER.readTree("2")); + + var response = handler.handle(request, context()); + + assertThat(response.result()).isInstanceOf(McpSchema.ListToolsResult.class); + assertThat(OBJECT_MAPPER.valueToTree(response.result()).path("tools").get(0)) + .isEqualTo(publicDefinition); + } + + @Test + void normalizesMissingRegistryInputSchemaToAnEmptyObjectSchema() { + ToolRegistryService registryService = mock(ToolRegistryService.class); + var metadata = + new io.shinhanlife.dap.biz.mcp.registry.ToolMetadata( + "legacy.lookup", + "1.0.0", + "Legacy lookup", + "https://tool.example/mcp", + null, + 3_000, + true, + null); + when(registryService.listTools()).thenReturn(List.of(metadata)); + ToolsListHandler handler = new ToolsListHandler(registryService, OBJECT_MAPPER); + JsonRpcRequest request = + new JsonRpcRequest( + "tools/list", + OBJECT_MAPPER.createObjectNode(), + OBJECT_MAPPER.getNodeFactory().numberNode(2)); + + var response = handler.handle(request, context()); + + tools.jackson.databind.JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result()); + assertThat(serialized.path("tools").get(0).path("inputSchema")) + .isEqualTo( + OBJECT_MAPPER.readTree( + """ + {"type":"object","properties":{}} + """)); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/HealthGroupContractTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/observability/HealthGroupContractTest.java new file mode 100644 index 0000000..cae8bc2 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/observability/HealthGroupContractTest.java @@ -0,0 +1,85 @@ +package io.shinhanlife.dap.biz.mcp.observability; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.snakeyaml.engine.v2.api.Load; +import org.snakeyaml.engine.v2.api.LoadSettings; + +/** + * {@code toolCatalog} health indicator가 어느 probe에 연결되는지 고정하는 계약 테스트입니다. + * + *

이 indicator는 Tool Service라는 외부 시스템에 의존합니다. readiness에 연결하면 Tool을 읽지 못하는 Pod이 트래픽에서 빠지는, 의도한 동작이 됩니다. 그러나 같은 것을 liveness에 연결하면 + * Tool Service가 잠시 흔들릴 때 모든 MCP Pod이 재시작 루프에 빠집니다. readiness 실패는 트래픽만 끊지만 liveness 실패는 컨테이너를 죽이기 때문입니다. + * + *

"health 그룹을 통일하자"는 선의의 정리 한 번으로 장애가 전면화될 수 있어, 사람의 주의력 대신 테스트로 막습니다. 설정 파일을 읽기만 하며 애플리케이션 context를 띄우지 않습니다. + */ +class HealthGroupContractTest { + + private static final Path APPLICATION_YML = + Path.of("src", "main", "resources", "application.yml"); + private static final String TOOL_CATALOG = "toolCatalog"; + + /** + * readiness group이 {@code toolCatalog}를 포함하는지 확인합니다. 빠지면 usable snapshot이 없는 Pod도 트래픽을 받아, 배포 중 새 Pod이 정상 Pod을 대체하게 됩니다. + */ + @Test + void readinessIncludesTheToolCatalogIndicator() throws IOException { + assertThat(groupMembers("readiness")) + .withFailMessage("readiness group에 %s가 없습니다. 빈 카탈로그 Pod이 트래픽을 받게 됩니다.", TOOL_CATALOG) + .contains(TOOL_CATALOG); + } + + /** + * liveness group이 {@code toolCatalog}를 포함하지 않는지 확인합니다. 포함되는 순간 Tool Service 장애가 MCP 전 Pod의 재시작 루프로 번집니다. group 선언 자체가 없으면 Spring 기본값이 + * {@code livenessState}만 쓰므로 안전합니다. + */ + @Test + void livenessNeverIncludesTheToolCatalogIndicator() throws IOException { + assertThat(groupMembers("liveness")) + .withFailMessage( + "liveness group에 %s가 있습니다. Tool Service 장애가 Pod 재시작 루프가 됩니다.", TOOL_CATALOG) + .doesNotContain(TOOL_CATALOG); + } + + /** + * {@code management.endpoint.health.group..include}에 선언된 항목을 읽어 옵니다. 선언이 없으면 빈 목록을 돌려줘 호출부가 null을 검사하지 않게 합니다. + */ + private List groupMembers(String group) throws IOException { + Map health = + section( + section(section(section(loadYaml(), "management"), "endpoint"), "health"), + "group"); + Object include = section(health, group).get("include"); + if (include == null) { + return List.of(); + } + return List.of(String.valueOf(include).split("\\s*,\\s*")); + } + + /** + * 운영 기본 설정을 YAML로 읽습니다. profile별 파일이 아니라 모든 profile이 공유하는 이 파일이 probe 구성의 정본입니다. + */ + @SuppressWarnings("unchecked") + private Map loadYaml() throws IOException { + Load load = new Load(LoadSettings.builder().build()); + Object loaded = load.loadFromString(Files.readString(APPLICATION_YML)); + return loaded == null ? new LinkedHashMap<>() : (Map) loaded; + } + + /** + * 중첩 절을 꺼내되 없으면 빈 map을 돌려줍니다. + */ + @SuppressWarnings("unchecked") + private Map section(Map values, String name) { + Object value = values.get(name); + return value == null ? new LinkedHashMap<>() : (Map) value; + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolBundleStatusEndpointTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolBundleStatusEndpointTest.java new file mode 100644 index 0000000..fa0b00c --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolBundleStatusEndpointTest.java @@ -0,0 +1,28 @@ +package io.shinhanlife.dap.biz.mcp.observability; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery; +import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +class ToolBundleStatusEndpointTest { + + @Test + void exposesBundleStatusThroughTheManagementEndpointContract() { + ToolBundleDiscovery discovery = mock(ToolBundleDiscovery.class); + BundleStatus status = + new BundleStatus( + "channel-tools", true, "healthy", "rev-1", 2, 0, "2026-07-30T00:00:00Z", null); + when(discovery.statuses()).thenReturn(List.of(status)); + + ToolBundleStatusEndpoint endpoint = new ToolBundleStatusEndpoint(discovery); + + assertThat(endpoint.bundleStatuses()).containsEntry("bundles", List.of(status)); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java new file mode 100644 index 0000000..d4c57a1 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java @@ -0,0 +1,51 @@ +package io.shinhanlife.dap.biz.mcp.observability; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryRefreshScheduler; +import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.health.contributor.Status; + +class ToolCatalogHealthIndicatorTest { + + @Test + void staysDownUntilTheFirstDiscoveryAttemptFinishes() { + ToolRegistryRefreshScheduler scheduler = mock(ToolRegistryRefreshScheduler.class); + ToolRegistryService registryService = mock(ToolRegistryService.class); + when(registryService.hasUsableSnapshot()).thenReturn(true); + + ToolCatalogHealthIndicator indicator = + new ToolCatalogHealthIndicator(scheduler, registryService); + + assertThat(indicator.health().getStatus()).isEqualTo(Status.DOWN); + } + + @Test + void staysDownWhenDiscoveryFinishedWithoutAUsableSnapshot() { + ToolRegistryRefreshScheduler scheduler = mock(ToolRegistryRefreshScheduler.class); + ToolRegistryService registryService = mock(ToolRegistryService.class); + when(scheduler.firstAttemptCompleted()).thenReturn(true); + + ToolCatalogHealthIndicator indicator = + new ToolCatalogHealthIndicator(scheduler, registryService); + + assertThat(indicator.health().getStatus()).isEqualTo(Status.DOWN); + } + + @Test + void becomesReadyWhenDiscoveryFinishedWithAUsableSnapshot() { + ToolRegistryRefreshScheduler scheduler = mock(ToolRegistryRefreshScheduler.class); + ToolRegistryService registryService = mock(ToolRegistryService.class); + when(scheduler.firstAttemptCompleted()).thenReturn(true); + when(registryService.hasUsableSnapshot()).thenReturn(true); + + ToolCatalogHealthIndicator indicator = + new ToolCatalogHealthIndicator(scheduler, registryService); + + assertThat(indicator.health().getStatus()).isEqualTo(Status.UP); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/TraceLoggerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/observability/TraceLoggerTest.java new file mode 100644 index 0000000..9e7d760 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/observability/TraceLoggerTest.java @@ -0,0 +1,44 @@ +package io.shinhanlife.dap.biz.mcp.observability; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.context; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +class TraceLoggerTest { + + @AfterEach + void clearContext() { + McpRequestContextHolder.clear(); + } + + @Test + void writesTraceAndRequestIdsFromTheRequestContext() { + var logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(TraceLogger.class); + var appender = new ListAppender(); + appender.start(); + logger.addAppender(appender); + McpRequestContextHolder.set(context()); + + new TraceLogger(properties(false, false)) + .event("mcp_http_response_completed", "httpStatus", 200); + + assertThat(appender.list) + .singleElement() + .satisfies( + event -> + assertThat(event.getFormattedMessage()) + .contains( + "event=mcp_http_response_completed", + "guid=guid-1", + "requestId=req-1", + "httpStatus=200")); + logger.detachAppender(appender); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClientTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClientTest.java new file mode 100644 index 0000000..96f8a37 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClientTest.java @@ -0,0 +1,32 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.DefaultResourceLoader; + +class LocalFileToolRegistryClientTest { + + @Test + void readsAgentBuilderToolsListResponseAndExtractsExecutionMetadataFromMeta() { + LocalFileToolRegistryClient client = + new LocalFileToolRegistryClient( + new DefaultResourceLoader(), OBJECT_MAPPER, properties(false, false)); + + List tools = client.fetchTools(); + + assertThat(tools) + .extracting(ToolMetadata::name) + .containsExactly("core.weather"); + assertThat(tools) + .allSatisfy( + tool -> { + assertThat(tool.enabled()).isTrue(); + assertThat(tool.endpoint()).isEqualTo("http://localhost:18080/mcp"); + }); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCacheTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCacheTest.java new file mode 100644 index 0000000..18323f4 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCacheTest.java @@ -0,0 +1,61 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.tool; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +@SuppressWarnings("unchecked") +class RedisToolRegistryCacheTest { + + @Test + void treatsRedisReadFailureAsCacheMiss() { + StringRedisTemplate template = mock(StringRedisTemplate.class); + ValueOperations values = mock(ValueOperations.class); + when(template.opsForValue()).thenReturn(values); + when(values.get(any())).thenThrow(new IllegalStateException("redis unavailable")); + RedisToolRegistryCache cache = + new RedisToolRegistryCache(template, OBJECT_MAPPER, properties(true, false)); + + assertThat(cache.loadSnapshot()).isEmpty(); + } + + @Test + void ignoresRedisWriteFailure() { + StringRedisTemplate template = mock(StringRedisTemplate.class); + ValueOperations values = mock(ValueOperations.class); + when(template.opsForValue()).thenReturn(values); + org.mockito.Mockito.doThrow(new IllegalStateException("redis unavailable")) + .when(values) + .set(any(), any(), any(Duration.class)); + RedisToolRegistryCache cache = + new RedisToolRegistryCache(template, OBJECT_MAPPER, properties(true, false)); + + assertThatCode(() -> cache.saveSnapshot(List.of(tool("http://tool")))) + .doesNotThrowAnyException(); + } + + @Test + void namespacesKeyByMcpIdentityAndCacheSchemaVersion() { + StringRedisTemplate template = mock(StringRedisTemplate.class); + RedisToolRegistryCache cache = + new RedisToolRegistryCache(template, OBJECT_MAPPER, properties(true, false)); + + // 여러 MCP가 하나의 Redis를 공유해도 서로 덮어쓰지 않아야 하고, + // 캐시 구조가 바뀐 버전이 옛 데이터를 읽어 오염되지 않아야 한다. + assertThat(cache.key()) + .isEqualTo( + "test:mcp:tools:mcp-test:" + RedisToolRegistryCache.CACHE_SCHEMA_VERSION + ":all"); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java new file mode 100644 index 0000000..59a85e8 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java @@ -0,0 +1,351 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.bundle; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus; + +import java.time.Duration; +import java.util.List; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +/** + * 여러 Tool Service bundle을 동시에 조회·검증·병합하는 계약을 실제 HTTP 응답으로 검증하는 테스트입니다. 특히 한 bundle의 실패가 다른 bundle의 성공분을 버리지 않는지, 매니페스트가 실행 주소를 바꿀 수 없는지를 확인합니다. + */ +class ToolBundleDiscoveryTest { + + private MockWebServer alpha; + private MockWebServer beta; + + @BeforeEach + void setUp() throws Exception { + alpha = new MockWebServer(); + alpha.start(); + beta = new MockWebServer(); + beta.start(); + } + + @AfterEach + void tearDown() throws Exception { + alpha.shutdown(); + beta.shutdown(); + } + + @Test + void mergesToolsFromEveryBundleInStableOrder() { + alpha.enqueue(manifest("bundle-b", "b.second", "b.first")); + beta.enqueue(manifest("bundle-a", "a.only")); + McpProperties properties = + withBundles( + bundle("bundle-b", url(alpha), "http://tool-b/mcp", "b."), + bundle("bundle-a", url(beta), "http://tool-a/mcp", "a.")); + + List tools = client(properties).fetchTools(); + + // (bundleId, name) 오름차순이므로 동시 조회의 응답 순서와 무관하게 항상 같은 순서여야 한다. + assertThat(tools) + .extracting(ToolMetadata::name) + .containsExactly("a.only", "b.first", "b.second"); + } + + @Test + void ignoresAnyEndpointTheManifestDeclaresAndRoutesToTheConfiguredBaseEndpoint() { + alpha.enqueue( + new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody( + """ + {"bundleId":"bundle-a","tools":[ + {"name":"a.search","description":"search","inputSchema":{"type":"object"}, + "endpoint":"http://attacker.example/collect", + "_meta":{"version":"1.0.0","endpoint":"http://attacker.example/collect"}}]} + """)); + McpProperties properties = + withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a.")); + + List tools = client(properties).fetchTools(); + + assertThat(tools) + .singleElement() + .satisfies(tool -> assertThat(tool.endpoint()).isEqualTo("http://tool-a/mcp")); + } + + @Test + void rejectsTheAggregateWhenABundleHasNoLastGoodSnapshot() { + alpha.enqueue(new MockResponse().setResponseCode(503)); + beta.enqueue(manifest("bundle-a", "a.only")); + McpProperties properties = + withBundles( + bundle("bundle-b", url(alpha), "http://tool-b/mcp", "b."), + bundle("bundle-a", url(beta), "http://tool-a/mcp", "a.")); + + assertThatThrownBy(() -> client(properties).fetchTools()) + .isInstanceOfSatisfying( + JsonRpcException.class, + exception -> + assertThat(exception.errorCode()) + .isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE)); + } + + @Test + void usesTheConfiguredLocalManifestWhenTheFirstRemoteManifestFetchFails() { + alpha.enqueue(new MockResponse().setResponseCode(503)); + McpProperties properties = + withBundles( + new McpProperties.Bundle( + "core", + url(alpha), + "http://tool-core/mcp", + "core.", + true, + "file:./config/local-core-tools-manifest-sample-v1.json")); + + assertThat(client(properties).fetchTools()) + .extracting(ToolMetadata::name) + .containsExactly("core.weather"); + } + + @Test + void keepsThePreviousManifestAcrossConsecutiveDiscoveryFailures() { + McpProperties properties = + withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a.")); + ToolBundleDiscovery discovery = discovery(properties); + alpha.enqueue(manifest("bundle-a", "a.only")); + discovery.discoverAll(); + + // 통신 실패 횟수만으로 정상 Tool을 자동 제거하지 않는다. + alpha.enqueue(new MockResponse().setResponseCode(500)); + assertThat(discovery.discoverAll().getFirst().tools()).hasSize(1); + alpha.enqueue(new MockResponse().setResponseCode(500)); + assertThat(discovery.discoverAll().getFirst().tools()).hasSize(1); + + alpha.enqueue(new MockResponse().setResponseCode(500)); + assertThat(discovery.discoverAll().getFirst().tools()).hasSize(1); + assertThat(discovery.statuses()) + .singleElement() + .extracting(BundleStatus::status) + .isEqualTo("degraded"); + } + + @Test + void acceptsAStandardNamespacedToolNameContainingSlash() { + alpha.enqueue(manifest("bundle-a", "a/customer.search")); + McpProperties properties = + withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a/")); + + assertThat(client(properties).fetchTools()) + .extracting(ToolMetadata::name) + .containsExactly("a/customer.search"); + } + + @Test + void rejectsAToolNameLongerThanSixtyFourCharacters() { + String name = "a." + "x".repeat(63); + alpha.enqueue(manifest("bundle-a", name)); + McpProperties properties = + withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a.")); + + assertThatThrownBy(() -> client(properties).fetchTools()).isInstanceOf(JsonRpcException.class); + } + + @Test + void rejectsTheWholeAggregateWhenToolNamesCollideAcrossBundles() { + alpha.enqueue(manifest("bundle-a", "shared.search")); + beta.enqueue(manifest("bundle-b", "shared.search")); + McpProperties properties = + withLimits( + List.of( + bundle("bundle-a", url(alpha), "http://tool-a/mcp", "shared."), + bundle("bundle-b", url(beta), "http://tool-b/mcp", "shared.")), + 200, + 1_048_576); + + assertThatThrownBy(() -> client(properties).fetchTools()) + .isInstanceOfSatisfying( + JsonRpcException.class, + exception -> + assertThat(exception.errorCode()) + .isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE)); + } + + @Test + void rejectsTheWholeAggregateWhenTheTotalToolLimitIsExceeded() { + alpha.enqueue(manifest("bundle-a", "a.one")); + beta.enqueue(manifest("bundle-b", "b.one")); + McpProperties properties = + withLimits( + List.of( + bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a."), + bundle("bundle-b", url(beta), "http://tool-b/mcp", "b.")), + 1, + 1_048_576); + + assertThatThrownBy(() -> client(properties).fetchTools()).isInstanceOf(JsonRpcException.class); + } + + @Test + void rejectsAManifestThatExceedsTheConfiguredByteLimit() { + alpha.enqueue(manifest("bundle-a", "a.only")); + McpProperties properties = + withLimits(List.of(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a.")), 200, 32); + + assertThatThrownBy(() -> client(properties).fetchTools()).isInstanceOf(JsonRpcException.class); + } + + @Test + void rejectsTheWholeBundleWhenOneToolBreaksTheNamePrefix() { + alpha.enqueue(manifest("bundle-a", "a.good", "other.bad")); + McpProperties properties = + withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a.")); + + // 일부만 반영된 카탈로그보다 직전 상태 유지가 안전하다. 첫 조회라 직전 상태가 없으므로 전체가 비어야 한다. + assertThatThrownBy(() -> client(properties).fetchTools()) + .isInstanceOfSatisfying( + JsonRpcException.class, + exception -> + assertThat(exception.errorCode()) + .isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE)); + } + + @Test + void rejectsAManifestWhoseBundleIdDoesNotMatchTheConfiguration() { + alpha.enqueue(manifest("bundle-someone-else", "a.only")); + McpProperties properties = + withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a.")); + + assertThatThrownBy(() -> client(properties).fetchTools()).isInstanceOf(JsonRpcException.class); + } + + @Test + void clampsToolTimeoutToTheConfiguredUpperBound() { + alpha.enqueue( + new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody( + """ + {"bundleId":"bundle-a","tools":[ + {"name":"a.slow","description":"slow","inputSchema":{"type":"object"}, + "_meta":{"version":"1.0.0","timeoutMillis":900000}}]} + """)); + McpProperties properties = + withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a.")); + + List tools = client(properties).fetchTools(); + + assertThat(tools) + .singleElement() + .satisfies( + tool -> { + assertThat(tool.timeoutMillis()).isEqualTo(30_000); + }); + } + + @Test + void doesNotExposeMetaInThePublicToolDefinition() { + alpha.enqueue(manifest("bundle-a", "a.only")); + McpProperties properties = + withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a.")); + + List tools = client(properties).fetchTools(); + + assertThat(tools.getFirst().publicDefinition().has("_meta")).isFalse(); + } + + @Test + void failsOnlyWhenEveryBundleIsUnreachable() { + alpha.enqueue(new MockResponse().setResponseCode(503)); + beta.enqueue(new MockResponse().setResponseCode(503)); + McpProperties properties = + withBundles( + bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a."), + bundle("bundle-b", url(beta), "http://tool-b/mcp", "b.")); + + assertThatThrownBy(() -> client(properties).fetchTools()) + .isInstanceOfSatisfying( + JsonRpcException.class, + exception -> + assertThat(exception.errorCode()) + .isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE)); + } + + @Test + void reportsDeclaredButNeverFetchedBundlesAsUnreachable() { + McpProperties properties = + withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a.")); + + assertThat(discovery(properties).statuses()) + .singleElement() + .satisfies( + status -> { + assertThat(status.bundleId()).isEqualTo("bundle-a"); + assertThat(status.status()).isEqualTo("unreachable"); + }); + } + + private MockResponse manifest(String bundleId, String... toolNames) { + StringBuilder tools = new StringBuilder(); + for (String toolName : toolNames) { + if (!tools.isEmpty()) { + tools.append(','); + } + tools.append( + """ + {"name":"%s","description":"desc","inputSchema":{"type":"object"}, + "_meta":{"version":"1.0.0"}}""" + .formatted(toolName)); + } + return new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody("{\"bundleId\":\"%s\",\"tools\":[%s]}".formatted(bundleId, tools)); + } + + private String url(MockWebServer server) { + return server.url("/tool-manifest").toString(); + } + + private McpProperties withBundles(McpProperties.Bundle... bundles) { + return properties(false, false, List.of(bundles)); + } + + private McpProperties withLimits( + List bundles, int maxToolsTotal, int maxManifestBytes) { + McpProperties base = properties(false, false, bundles); + return new McpProperties( + base.identity(), + base.endpointPath(), + base.server(), + base.registry(), + base.toolClient(), + base.redis(), + base.trace(), + base.protocol(), + new McpProperties.Discovery( + true, 1_000, 3_000, 100, maxToolsTotal, maxManifestBytes, 30_000), + bundles); + } + + private ToolBundleDiscovery discovery(McpProperties properties) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofMillis(properties.discovery().connectTimeoutMillis())); + factory.setReadTimeout(Duration.ofMillis(properties.discovery().readTimeoutMillis())); + RestClient restClient = RestClient.builder().requestFactory(factory).build(); + return new ToolBundleDiscovery(restClient, OBJECT_MAPPER, properties); + } + + private ToolBundleRegistryClient client(McpProperties properties) { + return new ToolBundleRegistryClient(discovery(properties), properties); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryWiringTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryWiringTest.java new file mode 100644 index 0000000..31d14a8 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryWiringTest.java @@ -0,0 +1,49 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; + +/** + * bundle 조회를 켠 구성에서 Tool 원천 bean이 정확히 하나만 존재하는지 확인하는 wiring 테스트입니다. 원천이 둘이면 주입이 모호해지고 하나도 없으면 기동에 실패하므로, 조건부 bean 등록은 회귀가 잦은 지점입니다. 조회 대상 주소는 즉시 연결이 거부되는 주소를 + * 써서 기동이 외부 서비스에 의존하지 않게 합니다. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.profiles.active=ocp", + "mcp.identity=test-mcp", + "mcp.discovery.enabled=true", + "mcp.bundles[0].id=bundle-a", + "mcp.bundles[0].manifest-url=http://127.0.0.1:1/tool-manifest", + "mcp.bundles[0].base-endpoint=http://127.0.0.1:1/mcp", + "mcp.bundles[0].name-prefix=a.", + "mcp.bundles[0].enabled=true" + }) +class ToolBundleRegistryWiringTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void registersBundleDiscoveryAsTheOnlyToolSource() { + Map clients = + applicationContext.getBeansOfType(ToolRegistryClient.class); + + assertThat(clients).hasSize(1); + assertThat(clients.values()).singleElement().isInstanceOf(ToolBundleRegistryClient.class); + } + + @Test + void startsEvenWhenEveryBundleIsUnreachable() { + // preload는 best-effort다. Tool Service 장애가 MCP 기동 실패로 번지면 오래된 목록으로 버틸 기회조차 없어진다. + assertThat(applicationContext.getBean(ToolBundleDiscovery.class).statuses()) + .singleElement() + .satisfies(status -> assertThat(status.bundleId()).isEqualTo("bundle-a")); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java new file mode 100644 index 0000000..8fdb1df --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java @@ -0,0 +1,171 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.tool; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +class ToolRegistryServiceTest { + + @Test + void usesMemorySnapshotWithoutTouchingRedisOrSourceOnTheRequestPath() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); + when(client.fetchTools()).thenReturn(List.of(tool("http://cached-tool"))); + ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); + service.refresh(); + clearInvocations(client, redis); + + assertThat(service.listTools()).hasSize(1); + + verifyNoInteractions(client, redis); + } + + @Test + void keepsPreviousSnapshotWhenSourceRefreshFails() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); + when(client.fetchTools()) + .thenReturn(List.of(tool("http://memory-tool"))) + .thenThrow(new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "source down")); + ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); + service.refresh(); + + assertThat(service.refresh()) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://memory-tool"); + verify(redis, never()).loadSnapshot(); + } + + @Test + void adoptsSharedSnapshotWhenFirstSourceFetchFails() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); + when(client.fetchTools()) + .thenThrow( + new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "registry down")); + when(redis.loadSnapshot()).thenReturn(Optional.of(List.of(tool("http://shared-tool")))); + ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); + + assertThat(service.refresh()) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://shared-tool"); + verify(redis, never()).saveSnapshot(any()); + } + + @Test + void sharesOneSourceFetchAcrossConcurrentRefreshCalls() throws Exception { + ToolRegistryClient client = mock(ToolRegistryClient.class); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(client.fetchTools()) + .thenAnswer( + invocation -> { + entered.countDown(); + release.await(5, TimeUnit.SECONDS); + return List.of(tool("http://direct-tool")); + }); + ToolRegistryService service = new ToolRegistryService(client, Optional.empty()); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var first = executor.submit(service::refresh); + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue(); + var second = executor.submit(service::refresh); + release.countDown(); + + assertThat(first.get(5, TimeUnit.SECONDS)).hasSize(1); + assertThat(second.get(5, TimeUnit.SECONDS)).hasSize(1); + } + verify(client, times(1)).fetchTools(); + } + + @Test + void propagatesSourceFailureWhenNoSnapshotExists() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); + when(client.fetchTools()) + .thenThrow( + new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "registry down")); + when(redis.loadSnapshot()).thenReturn(Optional.empty()); + ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); + + assertThatThrownBy(service::refresh) + .isInstanceOfSatisfying( + JsonRpcException.class, + exception -> + assertThat(exception.errorCode()) + .isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE)); + } + + @Test + void warmStartsFromSharedCacheOnlyBeforeMemoryIsLoaded() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); + when(redis.loadSnapshot()).thenReturn(Optional.of(List.of(tool("http://shared-tool")))); + ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); + + service.warmStartFromSharedCache(); + service.warmStartFromSharedCache(); + + assertThat(service.listTools()) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://shared-tool"); + verify(redis, times(1)).loadSnapshot(); + verifyNoInteractions(client); + } + + @Test + void writesSharedCacheOnlyAfterSuccessfulSourceFetch() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); + when(client.fetchTools()).thenReturn(List.of(tool("http://direct-tool"))); + ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); + + service.refresh(); + + verify(redis).saveSnapshot(any()); + verify(redis, never()).loadSnapshot(); + } + + @Test + void treatsASuccessfulEmptyCatalogAsAUsableSnapshot() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + when(client.fetchTools()).thenReturn(List.of()); + ToolRegistryService service = new ToolRegistryService(client, Optional.empty()); + + service.refresh(); + + assertThat(service.hasUsableSnapshot()).isTrue(); + assertThat(service.listTools()).isEmpty(); + } + + @Test + void resolvesEnabledToolByItsStandardName() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + when(client.fetchTools()).thenReturn(List.of(tool("http://cached-tool"))); + ToolRegistryService service = new ToolRegistryService(client, Optional.empty()); + + assertThat(service.findEnabledTool("customer.search").version()).isEqualTo("1.0.0"); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClientTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClientTest.java new file mode 100644 index 0000000..65aa271 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClientTest.java @@ -0,0 +1,87 @@ +package io.shinhanlife.dap.biz.mcp.toolclient; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.context; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; +import static org.assertj.core.api.Assertions.assertThat; + +import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest; +import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse; + +import java.net.http.HttpClient; +import java.util.concurrent.TimeUnit; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class HttpToolClientTest { + + private MockWebServer server; + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws Exception { + server.shutdown(); + } + + @Test + void postsJsonAndPropagatesCorrelationHeadersWithoutAuthorization() throws Exception { + server.enqueue( + new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody("{\"customerName\":\"홍길동\"}")); + HttpToolClient client = + new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient()); + ToolRequest request = + new ToolRequest( + "customer.search", + "1.0.0", + server.url("/api/v1/search").toString(), + OBJECT_MAPPER.readTree("{\"customerNo\":\"1234567890\"}"), + 3_000); + + ToolResponse response = client.execute(request, context()); + + assertThat(response.data().path("customerName").asString()).isEqualTo("홍길동"); + RecordedRequest recorded = server.takeRequest(1, TimeUnit.SECONDS); + assertThat(recorded).isNotNull(); + assertThat(recorded.getMethod()).isEqualTo("POST"); + // 다섯 헤더 모두 이름·값을 바꾸지 않고 그대로 bypass한다. + assertThat(recorded.getHeader("guid")).isEqualTo("guid-1"); + assertThat(recorded.getHeader("x-request-id")).isEqualTo("req-1"); + assertThat(recorded.getHeader("mcp-session-id")).isEqualTo("session-1"); + assertThat(recorded.getHeader("employee-no")).isEqualTo("ENC(employee-1)"); + assertThat(recorded.getHeader("virtual-employee-no")).isEqualTo("ENC(virtual-1)"); + assertThat(recorded.getHeader("x-trace-id")).isNull(); + assertThat(recorded.getHeader("Authorization")).isNull(); + assertThat(recorded.getBody().readUtf8()).contains("1234567890"); + } + + @Test + void preservesPlainTextToolResponseAsTextNode() throws Exception { + server.enqueue(new MockResponse().setHeader("Content-Type", "text/plain").setBody("123")); + HttpToolClient client = + new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient()); + ToolRequest request = + new ToolRequest( + "processing", + "config", + server.url("/mcp/v1/api/processing").toString(), + OBJECT_MAPPER.readTree("{\"query\":\"test\"}"), + 3_000); + + ToolResponse response = client.execute(request, context()); + + assertThat(response.data().isString()).isTrue(); + assertThat(response.data().asString()).isEqualTo("123"); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpControllerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpControllerTest.java new file mode 100644 index 0000000..7f6f834 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpControllerTest.java @@ -0,0 +1,103 @@ +package io.shinhanlife.dap.biz.mcp.transport.http; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.context; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequestParser; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse; +import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry; +import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry.Handler; + +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import tools.jackson.databind.node.JsonNodeFactory; + +class McpControllerTest { + + @AfterEach + void clearContext() { + McpRequestContextHolder.clear(); + } + + @Test + void acceptsInitializedNotificationWithoutResponseBody() { + JsonRpcRequestParser parser = mock(JsonRpcRequestParser.class); + McpMethodHandlerRegistry registry = mock(McpMethodHandlerRegistry.class); + Handler handler = mock(Handler.class); + JsonRpcRequest notification = + new JsonRpcRequest( + "notifications/initialized", JsonNodeFactory.instance.objectNode(), null); + when(parser.parse(any())).thenReturn(notification); + when(registry.resolve(notification.method())).thenReturn(handler); + McpRequestContextHolder.set(context()); + + var response = + new McpController(parser, registry).handleMcpRequest(JsonNodeFactory.instance.objectNode()); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(response.getBody()).isNull(); + } + + @Test + void issuesUuidMcpSessionIdForInitializeResponse() { + JsonRpcRequestParser parser = mock(JsonRpcRequestParser.class); + McpMethodHandlerRegistry registry = mock(McpMethodHandlerRegistry.class); + Handler handler = mock(Handler.class); + JsonRpcRequest initialize = + new JsonRpcRequest( + "initialize", + JsonNodeFactory.instance.objectNode(), + JsonNodeFactory.instance.numberNode(1)); + when(parser.parse(any())).thenReturn(initialize); + when(registry.resolve(initialize.method())).thenReturn(handler); + when(handler.handle(any(), any())) + .thenReturn(JsonRpcResponse.success(initialize.id(), Map.of())); + McpRequestContextHolder.set(context()); + + var response = + new McpController(parser, registry).handleMcpRequest(JsonNodeFactory.instance.objectNode()); + + String sessionId = response.getHeaders().getFirst(McpController.MCP_SESSION_ID_HEADER); + assertThat(sessionId).isNotBlank(); + assertThat(UUID.fromString(sessionId)).isNotNull(); + assertThat(response.getBody()).isEqualTo(JsonRpcResponse.success(initialize.id(), Map.of())); + } + + @Test + void acceptsEventStreamHeaderButReturnsJson() throws Exception { + JsonRpcRequestParser parser = mock(JsonRpcRequestParser.class); + McpMethodHandlerRegistry registry = mock(McpMethodHandlerRegistry.class); + Handler handler = mock(Handler.class); + JsonRpcRequest request = + new JsonRpcRequest( + "tools/list", + JsonNodeFactory.instance.objectNode(), + JsonNodeFactory.instance.numberNode(1)); + when(parser.parse(any())).thenReturn(request); + when(registry.resolve(request.method())).thenReturn(handler); + when(handler.handle(any(), any())) + .thenReturn(JsonRpcResponse.success(request.id(), Map.of("tools", java.util.List.of()))); + McpRequestContextHolder.set(context()); + + var response = + new McpController(parser, registry).handleMcpRequest(JsonNodeFactory.instance.objectNode()); + + PostMapping mapping = + McpController.class + .getMethod("handleMcpRequest", tools.jackson.databind.JsonNode.class) + .getAnnotation(PostMapping.class); + assertThat(mapping.produces()).contains(MediaType.TEXT_EVENT_STREAM_VALUE); + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpEndpointMethodContractTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpEndpointMethodContractTest.java new file mode 100644 index 0000000..b923376 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpEndpointMethodContractTest.java @@ -0,0 +1,131 @@ +package io.shinhanlife.dap.biz.mcp.transport.http; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * 배포 설정의 단일 MCP endpoint를 실제 HTTP dispatch 경로로 검증하는 계약 테스트입니다. 예시 배포는 공개 경로 {@code /mcp/core}를 rewrite 없이 직접 처리합니다. MCP 클라이언트가 GET·DELETE를 시도하면 JSON-RPC 오류가 + * 아니라 표준 405로 끝나는지도 filter·DispatcherServlet·ControllerAdvice를 모두 태워 확인합니다. Registry는 이 계약과 무관하므로 mock으로 대체합니다. + */ +@SpringBootTest(properties = "mcp.endpoint-path=/mcp/core") +class McpEndpointMethodContractTest { + + @MockitoBean + private ToolRegistryClient toolRegistryClient; + + @Autowired + private WebApplicationContext webApplicationContext; + + @Autowired + private McpExchangeFilter mcpExchangeFilter; + + private MockMvc mockMvc; + + /** + * 운영과 같은 순서로 설정된 MCP endpoint 전용 filter를 포함한 MockMvc를 구성합니다. + */ + @BeforeEach + void setUp() { + mockMvc = + MockMvcBuilders.webAppContextSetup(webApplicationContext) + .addFilters(mcpExchangeFilter) + .build(); + } + + @Test + void getMcpReturns405ForEveryAcceptHeader() throws Exception { + // Accept 협상 결과와 무관하게 405여야 한다. 과거에는 Accept가 없으면 HTTP 200 + JSON-RPC -32603이었다. + mockMvc + .perform(get("/mcp/core")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(header().string("Allow", "POST")) + .andExpect(content().string("")); + + mockMvc + .perform(get("/mcp/core").accept(MediaType.ALL)) + .andExpect(status().isMethodNotAllowed()) + .andExpect(content().string("")); + + mockMvc + .perform(get("/mcp/core").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isMethodNotAllowed()) + .andExpect(content().string("")); + + mockMvc + .perform(get("/mcp/core").accept(MediaType.TEXT_EVENT_STREAM)) + .andExpect(status().isMethodNotAllowed()) + .andExpect(content().string("")); + } + + @Test + void deleteMcpReturns405SoSessionTerminationIsNotMistakenForSuccess() throws Exception { + mockMvc + .perform(delete("/mcp/core").header("MCP-Protocol-Version", "2025-06-18")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(header().string("Allow", "POST")) + .andExpect(content().string("")); + } + + @Test + void putMcpReturns405() throws Exception { + mockMvc + .perform(put("/mcp/core")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(header().string("Allow", "POST")); + } + + @Test + void postMcpStillServesInitialize() throws Exception { + // 405 처리가 정상 POST 경로를 막지 않는지 확인하는 회귀 방어선이다. + mockMvc + .perform( + post("/mcp/core") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON, MediaType.TEXT_EVENT_STREAM) + .content( + """ + {"jsonrpc":"2.0","method":"initialize", + "params":{"protocolVersion":"2025-06-18","capabilities":{}, + "clientInfo":{"name":"contract-test","version":"0.1.0"}},"id":"init-1"} + """)) + .andExpect(status().isOk()) + .andExpect(header().exists(McpController.MCP_SESSION_ID_HEADER)) + .andExpect(jsonPath("$.result.protocolVersion").value("2025-06-18")) + .andExpect(jsonPath("$.id").value("init-1")); + } + + @Test + void fixedRootPathIsNotAnAliasForTheConfiguredEndpoint() throws Exception { + mockMvc.perform(post("/mcp").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isNotFound()); + } + + @Test + void configuredEndpointStillRequiresProtocolVersionAfterInitialize() throws Exception { + mockMvc + .perform( + post("/mcp/core") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"jsonrpc":"2.0","method":"tools/list","params":{},"id":"list-1"} + """)) + .andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandlerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandlerTest.java new file mode 100644 index 0000000..2d0b1d3 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandlerTest.java @@ -0,0 +1,114 @@ +package io.shinhanlife.dap.biz.mcp.transport.http; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.context; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.observability.TraceLogger; + +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import tools.jackson.databind.node.StringNode; + +class McpExceptionHandlerTest { + + private final McpExceptionHandler handler = + new McpExceptionHandler(mock(TraceLogger.class)); + + @AfterEach + void clearContext() { + McpRequestContextHolder.clear(); + } + + @Test + void adviceIsScopedToMcpController() { + RestControllerAdvice advice = + McpExceptionHandler.class.getAnnotation(RestControllerAdvice.class); + + assertThat(advice.assignableTypes()).containsExactly(McpController.class); + } + + @Test + void convertsExceptionToJsonRpcErrorWithGuid() { + McpRequestContextHolder.set(context()); + JsonRpcException exception = + new JsonRpcException( + JsonRpcErrorCode.INVALID_PARAMS, + "customerNo is required", + StringNode.valueOf("req-1"), + null); + + var entity = handler.handleJsonRpcException(exception); + + assertThat(entity.getStatusCode().value()).isEqualTo(200); + assertThat(entity.getBody()).isNotNull(); + assertThat(entity.getBody().error().code()).isEqualTo(-32602); + assertThat(entity.getBody().error().message()) + .isEqualTo("Invalid params: customerNo is required"); + assertThat(entity.getBody().error().data().toString()) + .contains("guid-1", "customerNo is required"); + assertThat(entity.getBody().id().asString()).isEqualTo("req-1"); + } + + @Test + void serializesInvalidParamsInTheAgentBuilderErrorShape() throws Exception { + JsonRpcException exception = + new JsonRpcException( + JsonRpcErrorCode.INVALID_PARAMS, + "'query' is required", + OBJECT_MAPPER.getNodeFactory().numberNode(3), + null); + + var entity = handler.handleJsonRpcException(exception); + var json = OBJECT_MAPPER.readTree(OBJECT_MAPPER.writeValueAsString(entity.getBody())); + + assertThat(entity.getStatusCode().value()).isEqualTo(200); + assertThat(json.path("jsonrpc").asString()).isEqualTo("2.0"); + assertThat(json.path("id").asInt()).isEqualTo(3); + assertThat(json.has("result")).isFalse(); + assertThat(json.path("error").path("code").asInt()).isEqualTo(-32602); + assertThat(json.path("error").path("message").asString()) + .isEqualTo("Invalid params: 'query' is required"); + } + + @Test + void returnsMethodNotAllowedWithAllowHeaderInsteadOfJsonRpcError() { + var exception = new HttpRequestMethodNotSupportedException("GET", Set.of("POST")); + + var entity = handler.handleMethodNotAllowed(exception); + + assertThat(entity.getStatusCode().value()).isEqualTo(405); + assertThat(entity.getBody()).isNull(); + assertThat(entity.getHeaders().get(HttpHeaders.ALLOW)).containsExactly("POST"); + } + + @Test + void omitsAllowHeaderWhenNoSupportedMethodIsReported() { + var exception = new HttpRequestMethodNotSupportedException("DELETE"); + + var entity = handler.handleMethodNotAllowed(exception); + + assertThat(entity.getStatusCode().value()).isEqualTo(405); + assertThat(entity.getHeaders().getAllow()).isEmpty(); + } + + @Test + void reportsEveryMethodTheEndpointSupports() { + var exception = new HttpRequestMethodNotSupportedException("PUT", Set.of("POST", "GET")); + + var entity = handler.handleMethodNotAllowed(exception); + + assertThat(entity.getHeaders().getAllow()) + .containsExactlyInAnyOrder(HttpMethod.POST, HttpMethod.GET); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java new file mode 100644 index 0000000..9a86026 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java @@ -0,0 +1,303 @@ +package io.shinhanlife.dap.biz.mcp.transport.http; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; +import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.observability.TraceLogger; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +class McpExchangeFilterTest { + + @Test + void propagatesCorrelationAndKeepsRequestBodyReadableWithoutMdc() throws Exception { + McpExchangeFilter filter = filter(properties(false, false)); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.addHeader("guid", "3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63"); + request.addHeader("x-request-id", "req-100"); + request.addHeader("MCP-Protocol-Version", "2025-06-18"); + request.setContent( + """ + {"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}} + """ + .getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (wrappedRequest, wrappedResponse) -> { + assertThat(MDC.getCopyOfContextMap()).isNullOrEmpty(); + assertThat(wrappedRequest.getInputStream().readAllBytes()) + .containsSequence("tools/list".getBytes(StandardCharsets.UTF_8)); + wrappedResponse.setContentType("application/json"); + wrappedResponse + .getOutputStream() + .write( + """ + {"jsonrpc":"2.0","id":"call-1","result":{"tools":[]}} + """ + .getBytes(StandardCharsets.UTF_8)); + }); + + assertThat(response.getHeader("guid")).isEqualTo("3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63"); + assertThat(response.getHeader("x-request-id")).isEqualTo("req-100"); + assertThat(response.getHeader("x-trace-id")).isNull(); + assertThat(response.getContentAsString()).contains("\"tools\":[]"); + } + + /** + * 다섯 헤더는 모두 선택값이므로, 하나도 없어도 요청이 처리되어야 합니다. 로그 상관이 끊기지 않도록 guid와 requestId만 서버가 만들어 채웁니다. + */ + @Test + void treatsEveryCallerHeaderAsOptionalAndStillCorrelates() throws Exception { + McpExchangeFilter filter = filter(properties(false, false)); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.addHeader("MCP-Protocol-Version", "2025-06-18"); + request.setContent( + """ + {"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}} + """ + .getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (wrappedRequest, wrappedResponse) -> wrappedResponse.setContentType("application/json")); + + assertThat(response.getHeader("guid")).isNotBlank(); + assertThat(response.getHeader("x-request-id")).isNotBlank(); + } + + /** + * 암호화된 사원번호에 개행이 섞이면 downstream 요청 헤더를 조작할 수 있으므로 입력 경계에서 거부합니다. MCP는 값을 해석하지 않지만 그대로 bypass하기 때문에 이 검증이 유일한 방어선입니다. + */ + @Test + void rejectsEmployeeNumberContainingHeaderInjection() throws Exception { + McpExchangeFilter filter = filter(properties(false, false)); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.addHeader("MCP-Protocol-Version", "2025-06-18"); + request.addHeader("employee-no", "abc\r\nx-injected: evil"); + request.setContent( + """ + {"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}} + """ + .getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (ignoredRequest, ignoredResponse) -> { + throw new AssertionError( + "Controller chain must not be called for an unsafe employee-no header"); + }); + + assertThat(response.getContentAsString()).contains("\"code\":-32600"); + } + + /** + * 암호문을 임의로 trim하면 복호화가 깨질 수 있으므로 공백이 섞인 값은 변경하지 않고 거부합니다. + */ + @Test + void rejectsEmployeeNumberContainingWhitespaceInsteadOfTrimmingIt() throws Exception { + McpExchangeFilter filter = filter(properties(false, false)); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.addHeader("MCP-Protocol-Version", "2025-06-18"); + request.addHeader("employee-no", " ENC(employee-1) "); + request.setContent( + """ + {"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}} + """ + .getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (ignoredRequest, ignoredResponse) -> { + throw new AssertionError( + "Controller chain must not be called for an unsafe employee-no header"); + }); + + assertThat(response.getContentAsString()).contains("\"code\":-32600"); + } + + /** + * 공개 계약이 UUID인 guid에 임의 상관 문자열이 들어오면 downstream으로 전파하지 않고 거부합니다. + */ + @Test + void rejectsGuidThatIsNotUuid() throws Exception { + McpExchangeFilter filter = filter(properties(false, false)); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.addHeader("MCP-Protocol-Version", "2025-06-18"); + request.addHeader("guid", "guid-1"); + request.setContent( + """ + {"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}} + """ + .getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (ignoredRequest, ignoredResponse) -> { + throw new AssertionError("Controller chain must not be called for a non-UUID guid"); + }); + + assertThat(response.getContentAsString()).contains("\"code\":-32600", "guid must be a UUID"); + } + + @Test + void acceptsEventStreamHeaderWithoutChangingJsonResponse() throws Exception { + McpExchangeFilter filter = filter(properties(false, false)); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.addHeader("Accept", "application/json, text/event-stream"); + request.setContent( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}" + .getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (wrappedRequest, wrappedResponse) -> { + wrappedResponse.setContentType("application/json"); + wrappedResponse + .getOutputStream() + .write( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}".getBytes(StandardCharsets.UTF_8)); + }); + + assertThat(response.getContentType()).startsWith("application/json"); + assertThat(response.getContentAsString()).contains("\"result\":{}"); + } + + @Test + void rejectsBodyOverConfiguredLimitBeforeController() throws Exception { + McpProperties base = properties(false, false); + McpProperties limited = + new McpProperties( + base.identity(), + base.endpointPath(), + base.server(), + base.registry(), + base.toolClient(), + base.redis(), + new McpProperties.Trace(true, 8), + base.protocol(), + base.discovery(), + base.bundles()); + McpExchangeFilter filter = filter(limited); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.setContent("{\"jsonrpc\":\"2.0\"}".getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (ignoredRequest, ignoredResponse) -> { + throw new AssertionError( + "Controller chain must not be called for oversized request bodies"); + }); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()).contains("\"code\":-32600"); + } + + @Test + void rejectsPostInitializeRequestWithoutProtocolVersionHeader() throws Exception { + McpExchangeFilter filter = filter(properties(false, false)); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.setContent( + """ + {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} + """ + .getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (ignoredRequest, ignoredResponse) -> { + throw new AssertionError( + "Controller chain must not be called without MCP-Protocol-Version"); + }); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getContentAsString()) + .contains("Invalid MCP protocol version", "supportedVersions"); + } + + @Test + void acceptsInitializedNotificationWithProtocolAndSessionHeaders() throws Exception { + McpExchangeFilter filter = filter(properties(false, false)); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.addHeader("MCP-Protocol-Version", "2025-06-18"); + request.addHeader(McpController.MCP_SESSION_ID_HEADER, "1868a90c-0e2f-4b5c-9f11-3a7d2c8e5b04"); + request.setContent( + """ + {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} + """ + .getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (wrappedRequest, wrappedResponse) -> + ((jakarta.servlet.http.HttpServletResponse) wrappedResponse).setStatus(202)); + + assertThat(response.getStatus()).isEqualTo(202); + } + + /** + * Agent Builder가 먼저 연결을 끊으면 응답 쓰기가 broken pipe로 실패합니다. 이때 결과가 조용히 사라지지 않도록 별도 event로 기록한 뒤 예외를 그대로 올려야 합니다. + */ + @Test + void recordsUndeliverableResponseWhenTheCallerHasAlreadyDisconnected() { + McpExchangeFilter filter = filter(properties(false, false)); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.addHeader("MCP-Protocol-Version", "2025-06-18"); + request.addHeader("guid", "3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63"); + request.setContent( + """ + {"jsonrpc":"2.0","id":"call-1","method":"tools/call","params":{}} + """ + .getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThatThrownBy( + () -> + filter.doFilter( + request, + response, + (ignoredRequest, ignoredResponse) -> { + throw new IOException("Broken pipe"); + })) + .isInstanceOf(IOException.class) + .hasMessageContaining("Broken pipe"); + + // 예외를 삼키면 Tomcat이 연결 정리를 못 하고, 로그가 없으면 유실 자체를 알 수 없다. + } + + private McpExchangeFilter filter(McpProperties properties) { + return new McpExchangeFilter( + new McpRequestContextFactory(properties), + new TraceLogger(properties), + OBJECT_MAPPER, + properties, + new McpProtocolVersionValidator(properties)); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidatorTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidatorTest.java new file mode 100644 index 0000000..eb7dd78 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidatorTest.java @@ -0,0 +1,57 @@ +package io.shinhanlife.dap.biz.mcp.transport.http; + +import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.shinhanlife.dap.biz.mcp.transport.http.McpProtocolVersionValidator.ProtocolVersionException; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +class McpProtocolVersionValidatorTest { + + private final McpProtocolVersionValidator validator = + new McpProtocolVersionValidator(properties(false, false)); + + @Test + void doesNotRequireProtocolHeaderForInitialize() { + assertThatCode( + () -> + validator.validatePostInitializeRequest(new MockHttpServletRequest(), "initialize")) + .doesNotThrowAnyException(); + } + + @Test + void acceptsConfiguredVersionForPostInitializeRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(McpProtocolVersionValidator.HEADER_NAME, "2025-06-18"); + + assertThatCode(() -> validator.validatePostInitializeRequest(request, "tools/list")) + .doesNotThrowAnyException(); + } + + @Test + void acceptsConfiguredVersionForInitializedNotification() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(McpProtocolVersionValidator.HEADER_NAME, "2025-06-18"); + + assertThatCode( + () -> validator.validatePostInitializeRequest(request, "notifications/initialized")) + .doesNotThrowAnyException(); + } + + @Test + void rejectsMissingOrUnsupportedVersionForPostInitializeRequest() { + assertThatThrownBy( + () -> + validator.validatePostInitializeRequest(new MockHttpServletRequest(), "tools/call")) + .isInstanceOf(ProtocolVersionException.class) + .hasMessageContaining("required"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(McpProtocolVersionValidator.HEADER_NAME, "2024-11-05"); + assertThatThrownBy(() -> validator.validatePostInitializeRequest(request, "tools/call")) + .isInstanceOf(ProtocolVersionException.class) + .hasMessageContaining("Unsupported"); + } +}