refs #712: authenticate backoffice MCP user tokens
This commit is contained in:
@@ -75,7 +75,7 @@ HMM_KNOWLEDGE_DOCUMENTS ──< HMM_KNOWLEDGE_CHUNKS ──< HMM_KNOWLEDGE_TAGS
|
|||||||
## HMM MCP 및 시스템 설정
|
## HMM MCP 및 시스템 설정
|
||||||
|
|
||||||
- 운영 MCP 주소는 환경변수 `BACKOFFICE_HMM_MCP_PUBLIC_URL`로 관리하며 기본값은
|
- 운영 MCP 주소는 환경변수 `BACKOFFICE_HMM_MCP_PUBLIC_URL`로 관리하며 기본값은
|
||||||
`https://hmm-mcp.cloud-handson.com/mcp`이다. 시스템 설정과 MCP 연동 화면은 이 값을 표시하므로
|
사용자 토큰용 주소는 `https://hmm-backoffice.cloud-handson.com/mcp`이다. 시스템 설정과 MCP 연동 화면은 이 값을 표시하므로
|
||||||
도메인 변경 시 화면 소스를 수정하지 않는다.
|
도메인 변경 시 화면 소스를 수정하지 않는다.
|
||||||
- HMM MCP와 백오피스 호환 `/mcp`는 동일하게 `resolve_hr_term`, `search_hr_data`,
|
- HMM MCP와 백오피스 호환 `/mcp`는 동일하게 `resolve_hr_term`, `search_hr_data`,
|
||||||
`search_hr_policy`를 제공한다. 각각 `HMM_HR_TERM_RESOLVER`,
|
`search_hr_policy`를 제공한다. 각각 `HMM_HR_TERM_RESOLVER`,
|
||||||
|
|||||||
78
docs/design/712-hmm-backoffice-mcp-bearer/README.md
Normal file
78
docs/design/712-hmm-backoffice-mcp-bearer/README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# HMM 백오피스 MCP 사용자 Bearer 인증 설계 (#712)
|
||||||
|
|
||||||
|
> 상태: 구현·배포·검증 완료
|
||||||
|
> 대상: `https://hmm-backoffice.cloud-handson.com/mcp`
|
||||||
|
> 브랜치: `hmm-backoffice`
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
HMM 백오피스에서 발급한 `vpd_live_*` 사용자 토큰을 MCP의 실제 인증 수단으로 사용한다.
|
||||||
|
현재 별도 호스트의 `https://hmm-mcp.cloud-handson.com/mcp`는 서버 공용 게이트웨이 토큰만
|
||||||
|
허용하므로 사용자 토큰을 보내면 Nginx에서 HTTP 401을 반환한다. 사용자별 HMM 데모는
|
||||||
|
백오피스 MCP 주소를 사용해야 한다.
|
||||||
|
|
||||||
|
## 확인된 현행 결함
|
||||||
|
|
||||||
|
- 백오피스의 `/mcp`는 `initialize`, `tools/list`, `tools/call`을 제공한다.
|
||||||
|
- Controller가 `Authorization: Bearer` 값을 추출하지만 Service의
|
||||||
|
`ignoredAuthorization` 인자로 전달해 검증하지 않는다.
|
||||||
|
- Tool 실행은 토큰 사용자 컨텍스트를 설정하지 않고 백오피스 JDBC 계정으로 바로
|
||||||
|
`DBMS_CLOUD_AI_AGENT.RUN_TOOL`을 호출한다.
|
||||||
|
- 따라서 주소를 올바르게 사용해도 토큰이 인증과 행 접근 문맥에 연결되지 않는다.
|
||||||
|
|
||||||
|
## 변경 설계
|
||||||
|
|
||||||
|
```text
|
||||||
|
Private Agent Factory
|
||||||
|
→ POST https://hmm-backoffice.cloud-handson.com/mcp
|
||||||
|
→ Authorization: Bearer <vpd_live 사용자 토큰>
|
||||||
|
→ HMM_ACCESS_BEARER_TOKENS SHA-256/만료/회수/재직 검증
|
||||||
|
→ 동일 JDBC connection에서 HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER
|
||||||
|
→ DBMS_CLOUD_AI_AGENT.RUN_TOOL
|
||||||
|
→ finally HMM_ACCESS_CTX_PKG.CLEAR_USER
|
||||||
|
```
|
||||||
|
|
||||||
|
1. 모든 MCP method는 활성 사용자 Bearer를 요구한다. 누락·오류·만료·회수 토큰은 HTTP 401로
|
||||||
|
fail-closed한다.
|
||||||
|
2. 원문 토큰은 로그, 응답, DB, Git에 기록하지 않는다. DB에는 기존 SHA-256 해시만 사용한다.
|
||||||
|
3. `tools/call`은 토큰 검증과 Tool 실행을 같은 요청에서 수행한다.
|
||||||
|
4. Tool 실행 connection에는 컨텍스트를 설정하고 성공·실패와 무관하게 `finally`에서 지운다.
|
||||||
|
5. `initialize`와 `tools/list`도 토큰을 검증해 discovery만으로 인증을 우회할 수 없게 한다.
|
||||||
|
|
||||||
|
## Agent Factory 설정
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` |
|
||||||
|
| Authentication mode | `Bearer Token` |
|
||||||
|
| Bearer token | 백오피스에서 발급한 토큰 원문만 입력 (`Bearer ` 접두어 제외) |
|
||||||
|
|
||||||
|
별도 서버 `https://hmm-mcp.cloud-handson.com/mcp`에는 사용자 토큰을 사용하지 않는다. 그 주소는
|
||||||
|
운영 `HMM_MCP_BEARER_TOKEN`을 사용하는 호환 게이트웨이다.
|
||||||
|
|
||||||
|
## 완료 기준
|
||||||
|
|
||||||
|
- 활성 사용자 토큰으로 `initialize`, `tools/list`, 세 Tool 호출이 성공한다.
|
||||||
|
- 누락·무효·회수 토큰은 HTTP 401이다.
|
||||||
|
- Tool 호출 전후 DB context 설정·정리가 자동 테스트로 검증된다.
|
||||||
|
- 운영 배포 후 외부 HTTPS에서 discovery와 대표 Tool 호출을 검증한다.
|
||||||
|
- 토큰 원문이나 해시는 테스트 출력과 문서에 남지 않는다.
|
||||||
|
|
||||||
|
## 배포 검증 결과
|
||||||
|
|
||||||
|
2026-07-23 운영 배포에서 다음을 확인했다.
|
||||||
|
|
||||||
|
- 자동 테스트 107건 통과
|
||||||
|
- 운영 JAR과 검증 빌드 SHA-256 일치
|
||||||
|
- 무토큰 `initialize`, `tools/list`: HTTP 401
|
||||||
|
- 활성 E1002 임시 사용자 토큰:
|
||||||
|
- `initialize`: HTTP 200
|
||||||
|
- `tools/list`: HTTP 200
|
||||||
|
- `resolve_hr_term`: HTTP 200, 정상 MCP result
|
||||||
|
- 발견 도구: `resolve_hr_term`, `search_hr_data`, `search_hr_policy`
|
||||||
|
- 같은 토큰을 회수한 직후 `tools/list`: HTTP 401
|
||||||
|
- 검증 중 발급한 임시 토큰과 이전 실패 시 남은 임시 토큰을 모두 회수
|
||||||
|
- 백오피스 시스템 설정과 MCP 서비스 화면의 Agent Factory 주소를
|
||||||
|
`https://hmm-backoffice.cloud-handson.com/mcp`로 변경
|
||||||
|
|
||||||
|
상세 증거는 `docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md`에 기록한다.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# HMM 백오피스 MCP 사용자 Bearer 검증 보고서
|
||||||
|
|
||||||
|
- 일자: 2026-07-23
|
||||||
|
- Redmine: #712
|
||||||
|
- 브랜치: `hmm-backoffice`
|
||||||
|
- 서비스: `https://hmm-backoffice.cloud-handson.com/mcp`
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
백오피스에서 발급한 `vpd_live_*` 직원 토큰을 별도 호환 서버인
|
||||||
|
`https://hmm-mcp.cloud-handson.com/mcp`에 보냈다. 이 서버의 Nginx는 운영 공용
|
||||||
|
`HMM_MCP_BEARER_TOKEN` 한 개만 비교하므로, 직원 토큰에서 `Bearer ` 문자열을 제거해도 HTTP 401이
|
||||||
|
정상이다.
|
||||||
|
|
||||||
|
직원 토큰용 주소는 `https://hmm-backoffice.cloud-handson.com/mcp`다. 조사 시 이 경로는 MCP 도구를
|
||||||
|
제공했지만 Authorization 값을 실제로 검증하지 않고 버리는 결함도 확인됐다.
|
||||||
|
|
||||||
|
## 수정
|
||||||
|
|
||||||
|
- 모든 MCP method에서 `HMM_ACCESS_BEARER_TOKENS`의 SHA-256 해시, 만료, 회수, 직원 재직 상태 검증
|
||||||
|
- 누락·무효·만료·회수 토큰은 HTTP 401
|
||||||
|
- Tool 호출 connection에서 `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER` 실행
|
||||||
|
- 같은 connection에서 `DBMS_CLOUD_AI_AGENT.RUN_TOOL` 실행
|
||||||
|
- 성공·실패와 무관하게 `finally`에서 `HMM_ACCESS_CTX_PKG.CLEAR_USER`
|
||||||
|
- Agent Factory 표시 주소를 백오피스 MCP 주소로 수정
|
||||||
|
- 공용 gateway와 직원 VPD endpoint의 주소·토큰 조합을 운영 문서에서 분리
|
||||||
|
|
||||||
|
토큰 원문은 Controller에서 `Bearer ` 접두어만 제거해 전달하며 로그·응답·DB에 기록하지 않는다.
|
||||||
|
DB에는 기존 SHA-256 해시와 식별용 prefix만 남는다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| Maven 자동 테스트 | 107건 통과 |
|
||||||
|
| 같은 JDBC connection의 context 설정·Tool 실행·context 정리 | 단위 테스트 통과 |
|
||||||
|
| 운영 JAR SHA-256 | 로컬 검증 빌드와 일치 |
|
||||||
|
| 서비스 | `vpd-backoffice.service` active |
|
||||||
|
| 로그인 상태 | HTTP 200 |
|
||||||
|
| 무토큰 MCP | HTTP 401 |
|
||||||
|
| 활성 E1002 임시 토큰 initialize | HTTP 200 |
|
||||||
|
| 활성 E1002 임시 토큰 tools/list | HTTP 200 |
|
||||||
|
| Tool discovery | HMM Tool 3개 |
|
||||||
|
| `resolve_hr_term` Tool 호출 | HTTP 200 / MCP result 성공 |
|
||||||
|
| 회수 후 같은 토큰 | HTTP 401 |
|
||||||
|
|
||||||
|
검증용 토큰은 백오피스의 정상 발급 흐름으로 만들고 원문을 출력하지 않았다. 검증 완료 시 즉시
|
||||||
|
회수했으며, 이전 실패 과정에서 남은 같은 용도의 임시 토큰 한 건도 함께 회수했다.
|
||||||
|
|
||||||
|
## Agent Factory 최종 입력
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| Server name | `hmm-backoffice-mcp` |
|
||||||
|
| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` |
|
||||||
|
| Authentication mode | `Bearer Token` |
|
||||||
|
| Bearer token | 백오피스 발급 토큰 원문만 입력 |
|
||||||
|
|
||||||
|
Token 입력란에 `Bearer ` 접두어를 직접 쓰지 않는다. Agent Factory가 HTTP
|
||||||
|
`Authorization: Bearer <token>` 헤더를 조립한다. OAuth URL과 client ID/secret은 사용하지 않는다.
|
||||||
140
docs/runbooks/hmm-mcp-token-configuration.md
Normal file
140
docs/runbooks/hmm-mcp-token-configuration.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# HMM MCP endpoint와 토큰 설정
|
||||||
|
|
||||||
|
## 인증 수단 구분
|
||||||
|
|
||||||
|
HMM 포털 로그인과 MCP 호출은 서로 다른 인증 수단을 사용한다.
|
||||||
|
|
||||||
|
| 인증 수단 | 사용 위치 | 전달 방식 | MCP 호출 사용 여부 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `__Host-HMM_PORTAL_SESSION` | `hmm.cloud-handson.com` 포털 로그인 | 브라우저 `Secure; HttpOnly` 쿠키 | 사용 금지 |
|
||||||
|
| `HMM_MCP_BEARER_TOKEN` | 별도 호환 MCP 서버 접근 | HTTP `Authorization: Bearer` | 포털의 공용 gateway에 사용 |
|
||||||
|
| HMM 직원별 VPD 토큰 | 백오피스 MCP 사용자 인증·DB context | HTTP `Authorization: Bearer` | Agent Factory 사용자별 연동에 사용 |
|
||||||
|
|
||||||
|
포털 쿠키를 복사해 MCP Bearer Token으로 사용하면 안 된다. 브라우저 JavaScript에서도 포털 쿠키를
|
||||||
|
읽을 수 없도록 `HttpOnly`로 설정한다.
|
||||||
|
|
||||||
|
## MCP endpoint 구분
|
||||||
|
|
||||||
|
두 주소는 같은 토큰을 받지 않는다.
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| 사용자별 VPD MCP | `https://hmm-backoffice.cloud-handson.com/mcp` |
|
||||||
|
| 사용자별 인증 | 백오피스에서 발급한 `vpd_live_*` 토큰 원문 |
|
||||||
|
| 공용 호환 MCP | `https://hmm-mcp.cloud-handson.com/mcp` |
|
||||||
|
| 공용 인증 | 운영 `HMM_MCP_BEARER_TOKEN` |
|
||||||
|
| Transport | Streamable HTTP POST |
|
||||||
|
|
||||||
|
허용 도구는 다음 세 개다.
|
||||||
|
|
||||||
|
| 도구 | 주요 인자 | 용도 |
|
||||||
|
|---|---|---|
|
||||||
|
| `search_hr_data` | `query` | 조직, 직원, 휴가 잔여·신청, 근태 조회 |
|
||||||
|
| `resolve_hr_term` | `term` | 휴가·근태 표현을 표준 용어와 코드로 변환 |
|
||||||
|
| `search_hr_policy` | `query` | HR 규정 PDF 지식 검색 |
|
||||||
|
|
||||||
|
## 포털의 현재 공용 토큰 조합
|
||||||
|
|
||||||
|
`config/vpd_token_presets.json`의 현재 조합은 다음과 같다.
|
||||||
|
|
||||||
|
| 데모 사용자 | 역할 | `mcp_token_env` |
|
||||||
|
|---|---|---|
|
||||||
|
| E1001 Kim Minseo | HR Team Manager | `HMM_MCP_BEARER_TOKEN` |
|
||||||
|
| E1002 Lee Jiwon | HR Operations Specialist | `HMM_MCP_BEARER_TOKEN` |
|
||||||
|
| E1003 Park Dohyun | People Analytics Analyst | `HMM_MCP_BEARER_TOKEN` |
|
||||||
|
| E1005 Han Seojun | Recruiting Specialist | `HMM_MCP_BEARER_TOKEN` |
|
||||||
|
| E1007 Kang Minho | HR Coordinator | `HMM_MCP_BEARER_TOKEN` |
|
||||||
|
|
||||||
|
현재는 모든 preset이 같은 서버 관리 토큰을 쓴다. preset을 바꾸면 질문에 포함되는 데모 사용자
|
||||||
|
문맥은 바뀌지만, Bearer Token 자체는 바뀌지 않는다. 따라서 이 조합만으로는 사용자별 VPD
|
||||||
|
보안 경계를 만들지 못한다.
|
||||||
|
|
||||||
|
## 포털 설정
|
||||||
|
|
||||||
|
`config/mcp_servers.json`에는 토큰 원문 대신 환경변수 이름만 기록한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "hmm_hr_mcp",
|
||||||
|
"endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp",
|
||||||
|
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
|
||||||
|
"tool_allowlist": [
|
||||||
|
"search_hr_data",
|
||||||
|
"resolve_hr_term",
|
||||||
|
"search_hr_policy"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
운영 서버 `/opt/hmm-poc4/.env`에 실제 값이 있어야 한다.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
HMM_MCP_BEARER_TOKEN=<MCP 서버에 등록된 동일한 임의 토큰>
|
||||||
|
POC3_MCP_TIMEOUT_SECONDS=45
|
||||||
|
```
|
||||||
|
|
||||||
|
토큰 원문은 JSON, Git, 대화 기록, 화면 상세에 저장하지 않는다. 환경 파일은 운영 계정만 읽을
|
||||||
|
수 있게 제한한다.
|
||||||
|
|
||||||
|
## 사용자 VPD MCP 직접 호출 예시
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export HMM_USER_BEARER_TOKEN='<백오피스 발급 화면에서 한 번 표시된 원문>'
|
||||||
|
|
||||||
|
curl --fail-with-body \
|
||||||
|
-H "Authorization: Bearer ${HMM_USER_BEARER_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Accept: application/json, text/event-stream" \
|
||||||
|
--data '{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "tools-list-1",
|
||||||
|
"method": "tools/list",
|
||||||
|
"params": {}
|
||||||
|
}' \
|
||||||
|
https://hmm-backoffice.cloud-handson.com/mcp
|
||||||
|
|
||||||
|
unset HMM_USER_BEARER_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
MCP 서버가 `initialize`와 session ID를 요구하면 다음 순서를 사용한다.
|
||||||
|
|
||||||
|
1. `initialize`
|
||||||
|
2. 응답의 `Mcp-Session-Id` 보관
|
||||||
|
3. `notifications/initialized`
|
||||||
|
4. 같은 session header와 Bearer Token으로 `tools/list`
|
||||||
|
5. 같은 session header와 Bearer Token으로 `tools/call`
|
||||||
|
|
||||||
|
## Oracle AI Database Private Agent Factory 설정
|
||||||
|
|
||||||
|
| 입력 항목 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| Server name | `hmm-backoffice-mcp` |
|
||||||
|
| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` |
|
||||||
|
| Authentication mode | Bearer Token |
|
||||||
|
| Token | 백오피스에서 해당 직원에게 발급한 토큰 원문. `Bearer ` 문자열은 붙이지 않음 |
|
||||||
|
| Allowed tools | 위 세 도구만 선택 |
|
||||||
|
| Timeout | 45초부터 시작 |
|
||||||
|
|
||||||
|
이 endpoint는 OAuth authorization endpoint가 아니다. OAuth client ID, client secret,
|
||||||
|
authorization URL, token URL은 입력하지 않는다.
|
||||||
|
|
||||||
|
## 사용자별 VPD 적용 구조
|
||||||
|
|
||||||
|
MCP의 표준 `Authorization` header는 하나이므로 “공통 gateway token + 직원 VPD token” 두 개를
|
||||||
|
같은 header에 조합하지 않는다. 다음 구조가 권장된다.
|
||||||
|
|
||||||
|
1. 백오피스에서 E1001, E1002 등 직원별 opaque token을 각각 발급한다.
|
||||||
|
2. MCP 서버는 그 직원 token 자체를 Bearer Token으로 검증한다.
|
||||||
|
3. 검증된 같은 token으로 DB 연결에서
|
||||||
|
`ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(:token)`을 호출한다.
|
||||||
|
4. 같은 DB 세션에서 `HMM_LEAVE_BALANCES`, `HMM_LEAVE_REQUESTS`를 조회한다.
|
||||||
|
5. `finally`에서 context를 초기화하고 connection pool에 반환한다.
|
||||||
|
6. 서버 관리용 공통 토큰과 직원별 토큰을 동시에 요구해야 한다면 OAuth/API Gateway에서
|
||||||
|
application identity와 user subject를 하나의 검증 가능한 access token으로 합친다.
|
||||||
|
|
||||||
|
이 흐름은 2026-07-23 백오피스 MCP에 적용됐다. 모든 MCP method가 토큰 해시·만료·회수·재직
|
||||||
|
상태를 확인하며, Tool 호출은 같은 JDBC connection에서 `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER`
|
||||||
|
후 실행하고 `finally`에서 context를 지운다. 무토큰·무효·회수 토큰은 HTTP 401이다.
|
||||||
|
|
||||||
|
포털의 `HMM_MCP_BEARER_TOKEN` 공용 preset은 별도 호환 gateway를 사용하는 기존 UI 라우팅이다.
|
||||||
|
Agent Factory의 사용자별 권한 검증에는 반드시 백오피스 MCP 주소와 직원 토큰을 사용한다.
|
||||||
@@ -7,5 +7,5 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
|||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface HmmAiAgentToolRunner {
|
public interface HmmAiAgentToolRunner {
|
||||||
|
|
||||||
JsonNode run(String toolName, ObjectNode input);
|
JsonNode run(String toolName, ObjectNode input, String bearerToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
/** Validates an HMM backoffice bearer token without retaining its plaintext value. */
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface HmmMcpBearerAuthenticator {
|
||||||
|
|
||||||
|
HmmMcpPrincipal authenticate(String bearerToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
/** Authenticated HMM employee identity resolved from a one-way bearer-token hash. */
|
||||||
|
public record HmmMcpPrincipal(
|
||||||
|
long employeeId,
|
||||||
|
String employeeCode,
|
||||||
|
Long teamId
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -3,7 +3,11 @@ package com.cloudhandson.vpdbackoffice.service;
|
|||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.node.TextNode;
|
import com.fasterxml.jackson.databind.node.TextNode;
|
||||||
|
import java.sql.CallableStatement;
|
||||||
import java.sql.Clob;
|
import java.sql.Clob;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import org.springframework.jdbc.core.ConnectionCallback;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -20,16 +24,45 @@ public class JdbcHmmAiAgentToolRunner implements HmmAiAgentToolRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public JsonNode run(String toolName, com.fasterxml.jackson.databind.node.ObjectNode input) {
|
public JsonNode run(
|
||||||
|
String toolName,
|
||||||
|
com.fasterxml.jackson.databind.node.ObjectNode input,
|
||||||
|
String bearerToken
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
String request = objectMapper.writeValueAsString(input);
|
String request = objectMapper.writeValueAsString(input);
|
||||||
String response = jdbcTemplate.queryForObject("""
|
String response = jdbcTemplate.execute((ConnectionCallback<String>) connection -> {
|
||||||
|
boolean contextSet = false;
|
||||||
|
try {
|
||||||
|
try (CallableStatement statement = connection.prepareCall(
|
||||||
|
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;")) {
|
||||||
|
statement.setString(1, bearerToken);
|
||||||
|
statement.execute();
|
||||||
|
contextSet = true;
|
||||||
|
}
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement("""
|
||||||
SELECT DBMS_CLOUD_AI_AGENT.RUN_TOOL(?, TO_CLOB(?))
|
SELECT DBMS_CLOUD_AI_AGENT.RUN_TOOL(?, TO_CLOB(?))
|
||||||
FROM dual
|
FROM dual
|
||||||
""", (resultSet, rowNum) -> {
|
""")) {
|
||||||
|
statement.setString(1, toolName);
|
||||||
|
statement.setString(2, request);
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
if (!resultSet.next()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
Clob clob = resultSet.getClob(1);
|
Clob clob = resultSet.getClob(1);
|
||||||
return clob == null ? "" : clob.getSubString(1, (int) clob.length());
|
return clob == null ? "" : clob.getSubString(1, (int) clob.length());
|
||||||
}, toolName, request);
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (contextSet) {
|
||||||
|
try (CallableStatement statement = connection.prepareCall(
|
||||||
|
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;")) {
|
||||||
|
statement.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
if (response == null || response.isBlank()) {
|
if (response == null || response.isBlank()) {
|
||||||
return objectMapper.createObjectNode();
|
return objectMapper.createObjectNode();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** Resolves active HMM employee tokens using only their SHA-256 hashes in ADB. */
|
||||||
|
@Service
|
||||||
|
public class JdbcHmmMcpBearerAuthenticator implements HmmMcpBearerAuthenticator {
|
||||||
|
|
||||||
|
private static final int MAX_TOKEN_LENGTH = 4096;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public JdbcHmmMcpBearerAuthenticator(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HmmMcpPrincipal authenticate(String bearerToken) {
|
||||||
|
if (bearerToken == null || bearerToken.isBlank() || bearerToken.length() > MAX_TOKEN_LENGTH) {
|
||||||
|
throw new McpUnauthorizedException();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return jdbcTemplate.queryForObject("""
|
||||||
|
SELECT employee.employee_id,
|
||||||
|
employee.employee_code,
|
||||||
|
employee.team_id
|
||||||
|
FROM hmm_access_bearer_tokens token
|
||||||
|
JOIN hmm_hr_employees employee
|
||||||
|
ON employee.employee_id = token.employee_id
|
||||||
|
WHERE token.key_hash = STANDARD_HASH(?, 'SHA256')
|
||||||
|
AND token.revoked_at IS NULL
|
||||||
|
AND token.expires_at > CAST(SYSTIMESTAMP AS TIMESTAMP)
|
||||||
|
AND employee.employment_status = 'ACTIVE'
|
||||||
|
""", (resultSet, rowNum) -> new HmmMcpPrincipal(
|
||||||
|
resultSet.getLong("employee_id"),
|
||||||
|
resultSet.getString("employee_code"),
|
||||||
|
resultSet.getObject("team_id", Long.class)
|
||||||
|
), bearerToken);
|
||||||
|
} catch (EmptyResultDataAccessException exception) {
|
||||||
|
throw new McpUnauthorizedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,10 +37,16 @@ public class McpSseService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
private final HmmAiAgentToolRunner agentToolRunner;
|
private final HmmAiAgentToolRunner agentToolRunner;
|
||||||
|
private final HmmMcpBearerAuthenticator bearerAuthenticator;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public McpSseService(HmmAiAgentToolRunner agentToolRunner, ObjectMapper objectMapper) {
|
public McpSseService(
|
||||||
|
HmmAiAgentToolRunner agentToolRunner,
|
||||||
|
HmmMcpBearerAuthenticator bearerAuthenticator,
|
||||||
|
ObjectMapper objectMapper
|
||||||
|
) {
|
||||||
this.agentToolRunner = agentToolRunner;
|
this.agentToolRunner = agentToolRunner;
|
||||||
|
this.bearerAuthenticator = bearerAuthenticator;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,11 +54,9 @@ public class McpSseService {
|
|||||||
return handle(contextPath, request, "");
|
return handle(contextPath, request, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Validates the user bearer before serving discovery or executing a tool. */
|
||||||
* Backoffice authentication protects this compatibility endpoint. The public
|
public ObjectNode handle(String contextPath, JsonNode request, String bearerToken) {
|
||||||
* HMM MCP endpoint performs its own fixed Bearer-token validation in Nginx.
|
bearerAuthenticator.authenticate(bearerToken);
|
||||||
*/
|
|
||||||
public ObjectNode handle(String contextPath, JsonNode request, String ignoredAuthorization) {
|
|
||||||
ObjectNode response = objectMapper.createObjectNode();
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
response.put("jsonrpc", "2.0");
|
response.put("jsonrpc", "2.0");
|
||||||
if (request != null && request.has("id")) {
|
if (request != null && request.has("id")) {
|
||||||
@@ -66,7 +70,7 @@ public class McpSseService {
|
|||||||
case "initialize" -> initializeResult(contextPath);
|
case "initialize" -> initializeResult(contextPath);
|
||||||
case "notifications/initialized" -> objectMapper.createObjectNode();
|
case "notifications/initialized" -> objectMapper.createObjectNode();
|
||||||
case "tools/list" -> toolsListResult();
|
case "tools/list" -> toolsListResult();
|
||||||
case "tools/call" -> toolsCallResult(parameters);
|
case "tools/call" -> toolsCallResult(parameters, bearerToken);
|
||||||
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
|
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
|
||||||
});
|
});
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
@@ -128,7 +132,7 @@ public class McpSseService {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ObjectNode toolsCallResult(JsonNode params) {
|
private ObjectNode toolsCallResult(JsonNode params, String bearerToken) {
|
||||||
String requestedName = params.path("name").asText("");
|
String requestedName = params.path("name").asText("");
|
||||||
ToolSpec tool = HMM_TOOLS.stream()
|
ToolSpec tool = HMM_TOOLS.stream()
|
||||||
.filter(candidate -> candidate.name().equals(requestedName))
|
.filter(candidate -> candidate.name().equals(requestedName))
|
||||||
@@ -140,7 +144,7 @@ public class McpSseService {
|
|||||||
}
|
}
|
||||||
ObjectNode input = objectMapper.createObjectNode();
|
ObjectNode input = objectMapper.createObjectNode();
|
||||||
input.put(tool.agentParameterName(), argument);
|
input.put(tool.agentParameterName(), argument);
|
||||||
JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input);
|
JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input, bearerToken);
|
||||||
|
|
||||||
ObjectNode payload = objectMapper.createObjectNode();
|
ObjectNode payload = objectMapper.createObjectNode();
|
||||||
payload.put("toolName", tool.name());
|
payload.put("toolName", tool.name());
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
/** Deliberately generic MCP authentication failure; never include token material. */
|
||||||
|
public class McpUnauthorizedException extends RuntimeException {
|
||||||
|
|
||||||
|
public McpUnauthorizedException() {
|
||||||
|
super("유효한 HMM 사용자 Bearer Token이 필요합니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.web;
|
package com.cloudhandson.vpdbackoffice.web;
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.service.McpSseService;
|
import com.cloudhandson.vpdbackoffice.service.McpSseService;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.McpUnauthorizedException;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -40,12 +41,18 @@ public class McpSseController {
|
|||||||
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization,
|
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization,
|
||||||
@RequestBody JsonNode request
|
@RequestBody JsonNode request
|
||||||
) {
|
) {
|
||||||
|
try {
|
||||||
|
ObjectNode response = mcpSseService.handle(
|
||||||
|
"default", request, bearerToken(authorization));
|
||||||
// JSON-RPC notifications never receive a response body. Current MCP
|
// JSON-RPC notifications never receive a response body. Current MCP
|
||||||
// clients send notifications/initialized immediately after initialize.
|
// clients send notifications/initialized immediately after initialize.
|
||||||
if (request != null && !request.has("id")) {
|
if (request != null && !request.has("id")) {
|
||||||
return ResponseEntity.accepted().build();
|
return ResponseEntity.accepted().build();
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(mcpSseService.handle("default", request, bearerToken(authorization)));
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (McpUnauthorizedException exception) {
|
||||||
|
return ResponseEntity.status(401).build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
@GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
@@ -98,8 +105,13 @@ public class McpSseController {
|
|||||||
JsonNode request
|
JsonNode request
|
||||||
) throws IOException {
|
) throws IOException {
|
||||||
String normalizedContextPath = normalizeContextPath(contextPath);
|
String normalizedContextPath = normalizeContextPath(contextPath);
|
||||||
ObjectNode response = mcpSseService.handle(
|
ObjectNode response;
|
||||||
|
try {
|
||||||
|
response = mcpSseService.handle(
|
||||||
normalizedContextPath, request, bearerToken(authorization));
|
normalizedContextPath, request, bearerToken(authorization));
|
||||||
|
} catch (McpUnauthorizedException exception) {
|
||||||
|
return ResponseEntity.status(401).build();
|
||||||
|
}
|
||||||
if (sessionId == null || sessionId.isBlank()) {
|
if (sessionId == null || sessionId.isBlank()) {
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ backoffice:
|
|||||||
username: ${BACKOFFICE_ORDS_DB_USERNAME:}
|
username: ${BACKOFFICE_ORDS_DB_USERNAME:}
|
||||||
password: ${BACKOFFICE_ORDS_DB_PASSWORD:}
|
password: ${BACKOFFICE_ORDS_DB_PASSWORD:}
|
||||||
mcp:
|
mcp:
|
||||||
public-url: ${BACKOFFICE_HMM_MCP_PUBLIC_URL:https://hmm-mcp.cloud-handson.com/mcp}
|
public-url: ${BACKOFFICE_HMM_MCP_PUBLIC_URL:https://hmm-backoffice.cloud-handson.com/mcp}
|
||||||
ai:
|
ai:
|
||||||
enabled: ${BACKOFFICE_AI_ENABLED:false}
|
enabled: ${BACKOFFICE_AI_ENABLED:false}
|
||||||
provider: ${BACKOFFICE_AI_PROVIDER:openai}
|
provider: ${BACKOFFICE_AI_PROVIDER:openai}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<div class="mcp-service-grid">
|
<div class="mcp-service-grid">
|
||||||
<div class="mcp-service-item">
|
<div class="mcp-service-item">
|
||||||
<span>공개 MCP Endpoint</span>
|
<span>공개 MCP Endpoint</span>
|
||||||
<strong><code th:text="${hmmMcpPublicUrl}">https://hmm-mcp.cloud-handson.com/mcp</code></strong>
|
<strong><code th:text="${hmmMcpPublicUrl}">https://hmm-backoffice.cloud-handson.com/mcp</code></strong>
|
||||||
<small>Private Agent Factory와 외부 MCP client가 사용하는 Streamable HTTP Endpoint입니다.</small>
|
<small>Private Agent Factory와 외부 MCP client가 사용하는 Streamable HTTP Endpoint입니다.</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="mcp-service-item">
|
<div class="mcp-service-item">
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<th>HMM MCP</th>
|
<th>HMM MCP</th>
|
||||||
<td><code th:text="${hmmMcpPublicUrl}">https://hmm-mcp.cloud-handson.com/mcp</code></td>
|
<td><code th:text="${hmmMcpPublicUrl}">https://hmm-backoffice.cloud-handson.com/mcp</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Transport</th>
|
<th>Transport</th>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
Public MCP endpoint
|
Public MCP endpoint
|
||||||
<input class="form-control" th:value="${hmmMcpPublicUrl}" readonly aria-readonly="true">
|
<input class="form-control" th:value="${hmmMcpPublicUrl}" readonly aria-readonly="true">
|
||||||
</label>
|
</label>
|
||||||
<p class="form-help">Agent Factory 등록 주소: <code th:text="${hmmMcpPublicUrl}">https://hmm-mcp.cloud-handson.com/mcp</code></p>
|
<p class="form-help">Agent Factory 등록 주소: <code th:text="${hmmMcpPublicUrl}">https://hmm-backoffice.cloud-handson.com/mcp</code></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.inOrder;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.sql.CallableStatement;
|
||||||
|
import java.sql.Clob;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.InOrder;
|
||||||
|
import org.springframework.jdbc.core.ConnectionCallback;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
class JdbcHmmAiAgentToolRunnerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setsAndClearsHMMContextOnTheSameConnection() throws Exception {
|
||||||
|
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||||
|
Connection connection = mock(Connection.class);
|
||||||
|
CallableStatement setContext = mock(CallableStatement.class);
|
||||||
|
CallableStatement clearContext = mock(CallableStatement.class);
|
||||||
|
PreparedStatement runTool = mock(PreparedStatement.class);
|
||||||
|
ResultSet resultSet = mock(ResultSet.class);
|
||||||
|
Clob clob = mock(Clob.class);
|
||||||
|
|
||||||
|
when(jdbcTemplate.execute(any(ConnectionCallback.class))).thenAnswer(invocation -> {
|
||||||
|
ConnectionCallback<?> callback = invocation.getArgument(0);
|
||||||
|
return callback.doInConnection(connection);
|
||||||
|
});
|
||||||
|
when(connection.prepareCall(
|
||||||
|
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;"))
|
||||||
|
.thenReturn(setContext);
|
||||||
|
when(connection.prepareCall("BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;"))
|
||||||
|
.thenReturn(clearContext);
|
||||||
|
when(connection.prepareStatement(any(String.class))).thenReturn(runTool);
|
||||||
|
when(runTool.executeQuery()).thenReturn(resultSet);
|
||||||
|
when(resultSet.next()).thenReturn(true);
|
||||||
|
when(resultSet.getClob(1)).thenReturn(clob);
|
||||||
|
when(clob.length()).thenReturn(15L);
|
||||||
|
when(clob.getSubString(1, 15)).thenReturn("{\"status\":\"ok\"}");
|
||||||
|
|
||||||
|
var runner = new JdbcHmmAiAgentToolRunner(jdbcTemplate, new ObjectMapper());
|
||||||
|
var result = runner.run(
|
||||||
|
"HMM_HR_TERM_RESOLVER",
|
||||||
|
new ObjectMapper().createObjectNode().put("P_TERM", "annual leave"),
|
||||||
|
"opaque-user-token");
|
||||||
|
|
||||||
|
assertThat(result.path("status").asText()).isEqualTo("ok");
|
||||||
|
InOrder order = inOrder(connection, setContext, runTool, clearContext);
|
||||||
|
order.verify(connection).prepareCall(
|
||||||
|
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;");
|
||||||
|
order.verify(setContext).setString(1, "opaque-user-token");
|
||||||
|
order.verify(setContext).execute();
|
||||||
|
order.verify(connection).prepareStatement(any(String.class));
|
||||||
|
order.verify(runTool).executeQuery();
|
||||||
|
order.verify(connection).prepareCall("BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;");
|
||||||
|
order.verify(clearContext).execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.service;
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -11,11 +12,14 @@ class McpSseServiceTest {
|
|||||||
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
private final CapturingHmmAiAgentToolRunner agentToolRunner = new CapturingHmmAiAgentToolRunner();
|
private final CapturingHmmAiAgentToolRunner agentToolRunner = new CapturingHmmAiAgentToolRunner();
|
||||||
private final McpSseService service = new McpSseService(agentToolRunner, objectMapper);
|
private final HmmMcpBearerAuthenticator bearerAuthenticator =
|
||||||
|
token -> new HmmMcpPrincipal(1L, "E1001", 1L);
|
||||||
|
private final McpSseService service =
|
||||||
|
new McpSseService(agentToolRunner, bearerAuthenticator, objectMapper);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void listsHMMTermDataAndPolicyToolsWithTheirActualInputs() {
|
void listsHMMTermDataAndPolicyToolsWithTheirActualInputs() {
|
||||||
ObjectNode response = service.handle("default", request(1, "tools/list"));
|
ObjectNode response = service.handle("default", request(1, "tools/list"), "valid-token");
|
||||||
|
|
||||||
var tools = response.path("result").path("tools");
|
var tools = response.path("result").path("tools");
|
||||||
assertThat(tools).hasSize(3);
|
assertThat(tools).hasSize(3);
|
||||||
@@ -39,10 +43,11 @@ class McpSseServiceTest {
|
|||||||
params.put("name", "resolve_hr_term");
|
params.put("name", "resolve_hr_term");
|
||||||
params.putObject("arguments").put("term", "연차 이월");
|
params.putObject("arguments").put("term", "연차 이월");
|
||||||
|
|
||||||
ObjectNode response = service.handle("default", request, "ignored-by-backoffice-session");
|
ObjectNode response = service.handle("default", request, "valid-token");
|
||||||
|
|
||||||
assertThat(agentToolRunner.toolName).isEqualTo("HMM_HR_TERM_RESOLVER");
|
assertThat(agentToolRunner.toolName).isEqualTo("HMM_HR_TERM_RESOLVER");
|
||||||
assertThat(agentToolRunner.input.path("P_TERM").asText()).isEqualTo("연차 이월");
|
assertThat(agentToolRunner.input.path("P_TERM").asText()).isEqualTo("연차 이월");
|
||||||
|
assertThat(agentToolRunner.bearerToken).isEqualTo("valid-token");
|
||||||
assertThat(response.path("error").isMissingNode()).isTrue();
|
assertThat(response.path("error").isMissingNode()).isTrue();
|
||||||
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
|
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
|
||||||
assertThat(response.path("result").path("content").get(0).path("text").asText())
|
assertThat(response.path("result").path("content").get(0).path("text").asText())
|
||||||
@@ -51,6 +56,20 @@ class McpSseServiceTest {
|
|||||||
.contains("ANNUAL_LEAVE_CARRYOVER");
|
.contains("ANNUAL_LEAVE_CARRYOVER");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDiscoveryWhenBearerAuthenticationFails() {
|
||||||
|
McpSseService rejectingService = new McpSseService(
|
||||||
|
agentToolRunner,
|
||||||
|
token -> {
|
||||||
|
throw new McpUnauthorizedException();
|
||||||
|
},
|
||||||
|
objectMapper);
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
rejectingService.handle("default", request(4, "tools/list"), "invalid-token"))
|
||||||
|
.isInstanceOf(McpUnauthorizedException.class);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsUnknownToolsWithoutCallingTheAgentRunner() {
|
void rejectsUnknownToolsWithoutCallingTheAgentRunner() {
|
||||||
ObjectNode request = request(3, "tools/call");
|
ObjectNode request = request(3, "tools/call");
|
||||||
@@ -58,7 +77,7 @@ class McpSseServiceTest {
|
|||||||
params.put("name", "ords.query.kb_select_ai_vpd");
|
params.put("name", "ords.query.kb_select_ai_vpd");
|
||||||
params.putObject("arguments").put("prompt", "legacy query");
|
params.putObject("arguments").put("prompt", "legacy query");
|
||||||
|
|
||||||
ObjectNode response = service.handle("default", request);
|
ObjectNode response = service.handle("default", request, "valid-token");
|
||||||
|
|
||||||
assertThat(response.path("result").isMissingNode()).isTrue();
|
assertThat(response.path("result").isMissingNode()).isTrue();
|
||||||
assertThat(response.path("error").path("message").asText()).contains("등록되지 않은 HMM MCP tool");
|
assertThat(response.path("error").path("message").asText()).contains("등록되지 않은 HMM MCP tool");
|
||||||
@@ -76,11 +95,17 @@ class McpSseServiceTest {
|
|||||||
|
|
||||||
private String toolName;
|
private String toolName;
|
||||||
private ObjectNode input;
|
private ObjectNode input;
|
||||||
|
private String bearerToken;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public JsonNode run(String requestedToolName, ObjectNode requestedInput) {
|
public JsonNode run(
|
||||||
|
String requestedToolName,
|
||||||
|
ObjectNode requestedInput,
|
||||||
|
String bearerToken
|
||||||
|
) {
|
||||||
toolName = requestedToolName;
|
toolName = requestedToolName;
|
||||||
input = requestedInput.deepCopy();
|
input = requestedInput.deepCopy();
|
||||||
|
this.bearerToken = bearerToken;
|
||||||
return objectMapper.createObjectNode()
|
return objectMapper.createObjectNode()
|
||||||
.put("termCode", "ANNUAL_LEAVE_CARRYOVER")
|
.put("termCode", "ANNUAL_LEAVE_CARRYOVER")
|
||||||
.put("termName", "연차 이월");
|
.put("termName", "연차 이월");
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ class SettingsTemplateRenderTest {
|
|||||||
var context = new Context(Locale.KOREAN);
|
var context = new Context(Locale.KOREAN);
|
||||||
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
|
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
|
||||||
context.setVariable("ordsBaseUrl", "https://ords.example.test/ords");
|
context.setVariable("ordsBaseUrl", "https://ords.example.test/ords");
|
||||||
context.setVariable("hmmMcpPublicUrl", "https://hmm-mcp.cloud-handson.com/mcp");
|
context.setVariable("hmmMcpPublicUrl", "https://hmm-backoffice.cloud-handson.com/mcp");
|
||||||
|
|
||||||
String connection = engine.process("settings", context);
|
String connection = engine.process("settings", context);
|
||||||
String database = engine.process("settings-database", context);
|
String database = engine.process("settings-database", context);
|
||||||
|
|
||||||
assertThat(connection)
|
assertThat(connection)
|
||||||
.contains("HMM HR Agent 도구")
|
.contains("HMM HR Agent 도구")
|
||||||
.contains("https://hmm-mcp.cloud-handson.com/mcp")
|
.contains("https://hmm-backoffice.cloud-handson.com/mcp")
|
||||||
.contains("Legacy ORDS Base URL")
|
.contains("Legacy ORDS Base URL")
|
||||||
.contains("/settings/database")
|
.contains("/settings/database")
|
||||||
.doesNotContain("g329127dfd380ad-kbaipoc")
|
.doesNotContain("g329127dfd380ad-kbaipoc")
|
||||||
|
|||||||
Reference in New Issue
Block a user