diff --git a/ai-web-agent-console/app.py b/ai-web-agent-console/app.py
index 26d6e08..275d3f6 100644
--- a/ai-web-agent-console/app.py
+++ b/ai-web-agent-console/app.py
@@ -2633,6 +2633,14 @@ def _mcp_response_payload(mcp_result: Any) -> Mapping[str, Any]:
if isinstance(mcp_result, Mapping):
response = mcp_result.get("response")
if isinstance(response, Mapping):
+ nested_result = response.get("result")
+ if isinstance(nested_result, str):
+ try:
+ parsed = json.loads(nested_result)
+ except ValueError:
+ parsed = None
+ if isinstance(parsed, Mapping):
+ return parsed
return response
return mcp_result
return {}
@@ -2643,6 +2651,19 @@ def _mcp_generated_sql(mcp_result: Any) -> str:
return str(payload.get("generatedSql") or payload.get("generated_sql") or "").strip()
+def _mcp_rendered_html(mcp_result: Any) -> str:
+ """Extract an HTML artifact without coupling the UI to a tool name."""
+
+ payload = _mcp_response_payload(mcp_result)
+ candidate = payload.get("html") or payload.get("rendered_html")
+ if not isinstance(candidate, str):
+ return ""
+ html_text = candidate.strip()
+ if len(html_text) > 1_000_000 or "<" not in html_text or ">" not in html_text:
+ return ""
+ return html_text
+
+
def _mcp_items(mcp_result: Any) -> list[Any]:
payload = _mcp_response_payload(mcp_result)
items = payload.get("items")
@@ -2692,11 +2713,91 @@ def _agent_tool_catalog(
if isinstance(properties, Mapping)
else [],
"read_only": route.tool.read_only,
+ "consumes_previous_result": _is_presentation_route(route),
}
)
return catalog, routes
+def _is_presentation_route(route: RoutedMcpTool) -> bool:
+ """Identify a renderer from its discovered description and input schema."""
+
+ properties = route.tool.schema.get("properties")
+ names = (
+ [str(name).casefold() for name in properties]
+ if isinstance(properties, Mapping)
+ else []
+ )
+ has_payload_input = any(
+ any(marker in name for marker in ("report", "payload", "render_data"))
+ and any(marker in name for marker in ("json", "data", "payload"))
+ for name in names
+ )
+ description = f"{route.tool.name} {route.tool.description}".casefold()
+ has_presentation_intent = any(
+ marker in description
+ for marker in ("render", "html", "dashboard", "presentation", "report")
+ )
+ has_prior_result_contract = any(
+ marker in description
+ for marker in (
+ "supplied",
+ "already-authorized",
+ "previous result",
+ "input data",
+ "후속 처리",
+ "조회 결과",
+ "선행 결과",
+ )
+ )
+ return has_payload_input and has_presentation_intent and has_prior_result_contract
+
+
+def _question_requests_presentation(question: str) -> bool:
+ text = str(question or "").casefold()
+ return any(
+ marker in text
+ for marker in (
+ "리포트",
+ "보고서",
+ "대시보드",
+ "차트",
+ "그래프",
+ "html",
+ "화면으로 만들어",
+ "문서로 만들어",
+ )
+ )
+
+
+def _presentation_route(
+ routes_by_key: Mapping[str, RoutedMcpTool],
+ attempted_route_keys: set[str],
+) -> tuple[str, RoutedMcpTool] | None:
+ return next(
+ (
+ (key, route)
+ for key, route in routes_by_key.items()
+ if key not in attempted_route_keys and _is_presentation_route(route)
+ ),
+ None,
+ )
+
+
+def _primary_data_route(
+ routes_by_key: Mapping[str, RoutedMcpTool],
+ attempted_route_keys: set[str],
+) -> tuple[str, RoutedMcpTool] | None:
+ return next(
+ (
+ (key, route)
+ for key, route in routes_by_key.items()
+ if key not in attempted_route_keys and not _is_presentation_route(route)
+ ),
+ None,
+ )
+
+
def _complex_reasoning_model_profile(default_model_profile: str) -> str:
configured = (
os.environ.get(COMPLEX_REASONING_MODEL_PROFILE_ENV)
@@ -2740,6 +2841,120 @@ def _clean_agent_tool_query(value: object, fallback: str) -> str:
return " ".join(cleaned).strip() or fallback
+def _camel_case_key(value: object) -> str:
+ text = str(value or "")
+ if "_" not in text:
+ return text[:1].lower() + text[1:]
+ parts = [part for part in text.casefold().split("_") if part]
+ return (
+ parts[0] + "".join(part[:1].upper() + part[1:] for part in parts[1:])
+ if parts
+ else text
+ )
+
+
+def _normalize_presentation_value(value: Any) -> Any:
+ if isinstance(value, Mapping):
+ return {
+ _camel_case_key(key): _normalize_presentation_value(item)
+ for key, item in value.items()
+ }
+ if isinstance(value, list):
+ return [_normalize_presentation_value(item) for item in value]
+ return value
+
+
+def _presentation_payload(question: str, steps: list[Mapping[str, Any]]) -> dict[str, Any]:
+ """Build a renderer payload only from the preceding authorized tool result."""
+
+ source = next(
+ (
+ step
+ for step in reversed(steps)
+ if not bool(step.get("consumes_previous_result"))
+ ),
+ {},
+ )
+ raw_result = source.get("mcp_result", {}) if isinstance(source, Mapping) else {}
+ payload = _mcp_response_payload(raw_result)
+ source_rows = _mcp_items(raw_result)
+ if not source_rows:
+ for key in ("results", "rows"):
+ candidate = payload.get(key) if isinstance(payload, Mapping) else None
+ if isinstance(candidate, list):
+ source_rows = candidate
+ break
+ rows = [
+ _normalize_presentation_value(row)
+ for row in source_rows
+ if isinstance(row, Mapping)
+ ]
+ requester = re.search(r"\bE\d{4,}\b", str(question or ""), flags=re.IGNORECASE)
+ source_tool = str(source.get("tool_name") or "MCP data query")
+ return {
+ "report": {
+ "id": "MCP-REPORT",
+ "category": "MCP Business Intelligence",
+ "title": str(question or "MCP 결과 리포트"),
+ "question": str(question or ""),
+ "requestedBy": requester.group(0).upper() if requester else "-",
+ "generatedAt": datetime.now(timezone.utc).isoformat(),
+ "answer": (
+ f"{source_tool}에서 권한 범위 내 결과 {len(rows)}건을 받아 "
+ "리포트로 구성했습니다."
+ ),
+ "execution": {
+ "server": str(source.get("server_id") or ""),
+ "tool": source_tool,
+ "calls": len(steps) + 1,
+ },
+ "evidence": [f"MCP {source_tool} 결과 {len(rows)}건 반환"],
+ "limitation": "표시 값은 선행 MCP 데이터 조회 결과만 사용했습니다.",
+ },
+ "rows": rows,
+ }
+
+
+def _build_mcp_arguments(
+ *,
+ route: RoutedMcpTool,
+ tool_query: str,
+ limit: int,
+ preferred_tool: str,
+ steps: list[Mapping[str, Any]],
+) -> dict[str, Any]:
+ if not _is_presentation_route(route):
+ return build_mcp_tool_arguments(
+ route.tool,
+ tool_query,
+ int(limit),
+ preferred_tool=preferred_tool,
+ )
+ properties = route.tool.schema.get("properties")
+ if not isinstance(properties, Mapping):
+ raise McpToolRouterError("렌더링 MCP tool의 입력 스키마가 없습니다.")
+ input_name = next(
+ (
+ str(name)
+ for name in properties
+ if "report" in str(name).casefold()
+ and any(
+ marker in str(name).casefold()
+ for marker in ("json", "payload", "data")
+ )
+ ),
+ "",
+ )
+ if not input_name:
+ raise McpToolRouterError("렌더링 MCP tool이 report payload 입력을 선언하지 않았습니다.")
+ return {
+ input_name: json.dumps(
+ _presentation_payload(tool_query, steps),
+ ensure_ascii=False,
+ )
+ }
+
+
def _mcp_has_actionable_result(mcp_result: Any) -> bool:
if _mcp_generated_sql(mcp_result) or _mcp_items(mcp_result):
return True
@@ -3231,6 +3446,29 @@ def plan_mcp_execution_mode(
"reason": f"사용자 선택: {override}",
"model_profile": "",
}
+ presentation_routes = [route for route in routed_tools if _is_presentation_route(route)]
+ if _question_requests_presentation(question) and presentation_routes:
+ data_routes = [route for route in routed_tools if not _is_presentation_route(route)]
+ primary = data_routes[0] if data_routes else fallback
+ if len(data_routes) > 1:
+ try:
+ primary = route_mcp_tool_across_servers_with_llm(
+ data_routes,
+ question,
+ router_model_profile=model_profile_key,
+ )
+ except McpToolRouterError:
+ pass
+ if primary is not fallback or len(routed_tools) > 1:
+ return {
+ "mode": "agent",
+ "route_key": _route_key(primary.server_id, primary.tool.name),
+ "reason": (
+ "리포트·차트·HTML 요청이며, 발견된 렌더링 도구가 이전 구조화 "
+ "결과를 입력으로 선언했습니다. 데이터 조회 후 렌더링을 순차 실행합니다."
+ ),
+ "model_profile": model_profile_key,
+ }
if len(routed_tools) <= 1:
return {
"mode": "single",
@@ -3276,7 +3514,10 @@ def plan_mcp_execution_mode(
"the user's Korean question should be answered with one MCP "
"tool call or with a multi-tool ReAct loop. Prefer mode=single "
"unless the question explicitly requires comparing, combining, "
- "or validating evidence across different MCP tools/sources. "
+ "or validating evidence across different MCP tools/sources. A route "
+ "whose consumes_previous_result flag is true is a presentation renderer: "
+ "when the user asks for a report, dashboard, chart, document, or HTML, "
+ "choose mode=agent so a data route runs first and that renderer runs next. "
"Return only JSON matching the schema. Never request or expose "
"bearer tokens."
),
@@ -3290,7 +3531,8 @@ def plan_mcp_execution_mode(
),
"agent_policy": (
"use agent only for cross-source comparison, contract "
- "plus terms/document search, or multi-step validation"
+ "plus terms/document search, multi-step validation, or a "
+ "data-result-to-presentation rendering sequence"
),
},
ensure_ascii=False,
@@ -3360,6 +3602,10 @@ def _plan_agent_step(
"If no tool has been called yet, choose action=call_tool. "
"After each observation, decide whether another tool call is needed "
"or action=final_answer is enough. Do not expose or request bearer tokens. "
+ "A route with consumes_previous_result=true is a renderer. For a report, "
+ "dashboard, chart, document, or HTML request, call a data route first, "
+ "then call that renderer using the previous structured result; do not "
+ "finish with text only before the renderer has been attempted. "
"tool_query must be plain natural-language query text only; do not include "
"argument labels such as limit:, prompt:, query:, top_k:, or candidate_k:. "
"Do not write SQL. Preserve identifiers exactly. If the user says "
@@ -3417,6 +3663,7 @@ def run_mcp_agent_loop(
bearer_token: str,
limit: int,
model_profile_key: str,
+ initial_route_key: str = "",
progress_callback: Any = None,
) -> dict[str, Any]:
tool_catalog, routes_by_key = _agent_tool_catalog(routed_tools)
@@ -3431,8 +3678,21 @@ def run_mcp_agent_loop(
for step_no in range(1, MAX_AGENT_TOOL_STEPS + 1):
forced_plan = None
+ wants_presentation = _question_requests_presentation(question)
+ if wants_presentation and _presentation_route(routes_by_key, attempted_route_keys):
+ forced_plan = (
+ (
+ (initial_route_key, routes_by_key[initial_route_key])
+ if not observations
+ and initial_route_key in routes_by_key
+ and not _is_presentation_route(routes_by_key[initial_route_key])
+ else _primary_data_route(routes_by_key, attempted_route_keys)
+ )
+ if not observations
+ else _presentation_route(routes_by_key, attempted_route_keys)
+ )
if step_no == 1 and _question_needs_cross_source(question):
- forced_plan = _select_unvisited_route(
+ forced_plan = forced_plan or _select_unvisited_route(
question,
routes_by_key,
attempted_route_keys,
@@ -3522,36 +3782,48 @@ def run_mcp_agent_loop(
tool_query = _clean_agent_tool_query(plan.get("tool_query"), question)
if action == "final_answer" and observations:
- forced = _select_unvisited_route(
- question,
- routes_by_key,
- attempted_route_keys,
+ renderer = (
+ _presentation_route(routes_by_key, attempted_route_keys)
+ if wants_presentation
+ else None
)
- if forced is None or not _question_needs_cross_source(question):
- stop_reason = "planner가 추가 MCP 호출이 불필요하다고 판단했습니다."
- break
- route_key, route = forced
- action = "call_tool"
- tool_query = _fallback_tool_query_for_route(
- question,
- route,
- observations,
- )
- thought = (
- f"{thought} / 비교·약관 질문인데 미호출 MCP route가 있어 "
- f"{route_key} 호출로 전환합니다."
- ).strip(" /")
+ if renderer is not None:
+ route_key, route = renderer
+ action = "call_tool"
+ tool_query = question
+ thought = "사용자 요청에 따른 이전 구조화 결과의 리포트 렌더링"
+ else:
+ forced = _select_unvisited_route(
+ question,
+ routes_by_key,
+ attempted_route_keys,
+ )
+ if forced is None or not _question_needs_cross_source(question):
+ stop_reason = "planner가 추가 MCP 호출이 불필요하다고 판단했습니다."
+ break
+ route_key, route = forced
+ action = "call_tool"
+ tool_query = _fallback_tool_query_for_route(
+ question,
+ route,
+ observations,
+ )
+ thought = (
+ f"{thought} / 비교·약관 질문인데 미호출 MCP route가 있어 "
+ f"{route_key} 호출로 전환합니다."
+ ).strip(" /")
if action != "call_tool" and not observations:
action = "call_tool"
route = routes_by_key.get(route_key)
if route is None:
route = next(iter(routes_by_key.values()))
route_key = _route_key(route.server_id, route.tool.name)
- tool_query = append_query_contract_guidance(
- tool_query,
- original_question=question,
- tool_name=route.tool.name,
- )
+ if not _is_presentation_route(route):
+ tool_query = append_query_contract_guidance(
+ tool_query,
+ original_question=question,
+ tool_name=route.tool.name,
+ )
is_distinct_vector_query = (
_is_vector_route(route)
and tool_query not in completed_vector_queries
@@ -3576,11 +3848,12 @@ def run_mcp_agent_loop(
route,
observations,
)
- tool_query = append_query_contract_guidance(
- tool_query,
- original_question=question,
- tool_name=route.tool.name,
- )
+ if not _is_presentation_route(route):
+ tool_query = append_query_contract_guidance(
+ tool_query,
+ original_question=question,
+ tool_name=route.tool.name,
+ )
thought = (
f"{thought} / 중복 MCP route {repeated_route_key} 대신 "
f"미호출 route {route_key}로 전환합니다."
@@ -3590,11 +3863,12 @@ def run_mcp_agent_loop(
raise PublicMcpError("선택된 MCP 서버 설정을 찾지 못했습니다.")
started = perf_counter()
- arguments = build_mcp_tool_arguments(
- route.tool,
- tool_query,
- int(limit),
+ arguments = _build_mcp_arguments(
+ route=route,
+ tool_query=question if _is_presentation_route(route) else tool_query,
+ limit=int(limit),
preferred_tool=server.default_tool,
+ steps=steps,
)
raw_result = call_tool(
base_url=server.endpoint_url,
@@ -3618,6 +3892,7 @@ def run_mcp_agent_loop(
"mcp_result": mcp_result,
"result_summary": summary,
"elapsed_seconds": round(elapsed, 3),
+ "consumes_previous_result": _is_presentation_route(route),
}
steps.append(step)
observations.append(
@@ -3642,6 +3917,9 @@ def run_mcp_agent_loop(
actionable_route_keys.add(route_key)
if progress_callback:
progress_callback(step)
+ if _is_presentation_route(route):
+ stop_reason = "이전 구조화 결과를 렌더링 MCP tool로 전달했습니다."
+ break
if last is None:
raise PublicMcpError("MCP agent가 실행한 tool 호출이 없습니다.")
@@ -3666,6 +3944,11 @@ def _render_mcp_result_sections(
execution_events = details.get("execution_events")
generated_sql = _mcp_generated_sql(mcp_result)
items = _mcp_items(mcp_result)
+ rendered_html = _mcp_rendered_html(mcp_result)
+
+ if rendered_html:
+ st.markdown("#### 생성된 리포트")
+ components.html(rendered_html, height=1420, scrolling=True)
if isinstance(execution_events, list) and execution_events:
with st.expander(f"처리 시간 로그 · {len(execution_events)}건"):
@@ -5031,6 +5314,10 @@ def _process_submitted_question(
bearer_token=bearer_token,
limit=int(limit),
model_profile_key=reasoning_model_profile,
+ initial_route_key=_route_key(
+ selected_route.server_id,
+ selected_route.tool.name,
+ ),
progress_callback=on_agent_step,
)
selected_server = agent_result["server"]
diff --git a/ai-web-agent-console/assets/hmm-carrier-performance-report.html b/ai-web-agent-console/assets/hmm-carrier-performance-report.html
new file mode 100644
index 0000000..1c5a81f
--- /dev/null
+++ b/ai-web-agent-console/assets/hmm-carrier-performance-report.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+ HMM | 팀 포트폴리오 선사 실적
+
+
+
+
+
+
+
diff --git a/ai-web-agent-console/config/mcp_servers.json b/ai-web-agent-console/config/mcp_servers.json
index 3da95f4..e6165df 100644
--- a/ai-web-agent-console/config/mcp_servers.json
+++ b/ai-web-agent-console/config/mcp_servers.json
@@ -6,7 +6,7 @@
"enabled": true,
"provider": "hmm_compat_mcp",
"transport": "http",
- "endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp",
+ "endpoint_url": "https://hmm-backoffice.cloud-handson.com/mcp",
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
"timeout_seconds_env": "AI_WEB_AGENT_CONSOLE_MCP_TIMEOUT_SECONDS",
"default_tool": "search_hr_data",
@@ -15,7 +15,8 @@
"search_hr_data",
"resolve_hr_term",
"search_hr_policy",
- "search_carrier_performance"
+ "search_carrier_performance",
+ "render_hmm_carrier_report"
],
"description": "HMM HR knowledge, ADB employee assignment, and RDS carrier performance MCP server"
}
diff --git a/ai-web-agent-console/tests/test_scenarios.py b/ai-web-agent-console/tests/test_scenarios.py
index 7c0b3e6..39fff54 100644
--- a/ai-web-agent-console/tests/test_scenarios.py
+++ b/ai-web-agent-console/tests/test_scenarios.py
@@ -123,6 +123,34 @@ class DemoScenarioConfigTest(unittest.TestCase):
server["tool_allowlist"],
)
+ def test_hmm_mcp_allows_dynamic_html_renderer(self) -> None:
+ root = Path(__file__).parents[1]
+ payload = json.loads(
+ (root / "config" / "mcp_servers.json").read_text(encoding="utf-8")
+ )
+ server = next(
+ item for item in payload["servers"] if item["id"] == "hmm_hr_mcp"
+ )
+ source = (root / "app.py").read_text(encoding="utf-8")
+
+ self.assertEqual(
+ server["endpoint_url"],
+ "https://hmm-backoffice.cloud-handson.com/mcp",
+ )
+ self.assertIn("render_hmm_carrier_report", server["tool_allowlist"])
+ self.assertNotIn('tool.name == "render_hmm_carrier_report"', source)
+
+ def test_hmm_report_template_contains_only_dynamic_payload_slot(self) -> None:
+ template = (
+ Path(__file__).parents[1]
+ / "assets"
+ / "hmm-carrier-performance-report.html"
+ ).read_text(encoding="utf-8")
+
+ self.assertEqual(template.count("__REPORT_DATA__"), 1)
+ self.assertNotIn("Bluewave Maritime", template)
+ self.assertNotIn("Southern Cross Marine", template)
+
def test_hmm_demo_user_presets_reference_runtime_token_only(self) -> None:
path = Path(__file__).parents[1] / "config" / "vpd_token_presets.json"
payload = json.loads(path.read_text(encoding="utf-8"))
diff --git a/database/adb/83_hmm_carrier_html_report_tool.sql b/database/adb/83_hmm_carrier_html_report_tool.sql
new file mode 100644
index 0000000..2eeaa2c
--- /dev/null
+++ b/database/adb/83_hmm_carrier_html_report_tool.sql
@@ -0,0 +1,72 @@
+-- HMM carrier report template storage and DBMS_CLOUD_AI_AGENT custom tool.
+-- Run as ADMIN. Load the approved HTML template into HMM_REPORT_TEMPLATES
+-- through the deployment loader before enabling the MCP tool.
+WHENEVER SQLERROR EXIT SQL.SQLCODE ROLLBACK
+SET DEFINE OFF
+
+CREATE TABLE hmm_report_templates (
+ template_key VARCHAR2(64) PRIMARY KEY,
+ template_version VARCHAR2(32) NOT NULL,
+ html_template CLOB NOT NULL,
+ active_yn CHAR(1) DEFAULT 'Y' NOT NULL,
+ created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
+ updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
+ CONSTRAINT hmm_report_templates_active_ck CHECK (active_yn IN ('Y', 'N'))
+);
+
+CREATE OR REPLACE PACKAGE hmm_report_render_pkg AUTHID DEFINER AS
+ FUNCTION render_carrier_report(p_report_json IN CLOB) RETURN CLOB;
+END hmm_report_render_pkg;
+/
+
+CREATE OR REPLACE PACKAGE BODY hmm_report_render_pkg AS
+ FUNCTION render_carrier_report(p_report_json IN CLOB) RETURN CLOB IS
+ l_template CLOB;
+ l_data CLOB;
+ l_html VARCHAR2(32767);
+ l_result CLOB;
+ BEGIN
+ IF p_report_json IS NULL OR dbms_lob.getlength(p_report_json) > 64000 THEN
+ raise_application_error(-20101, 'Invalid report payload size.');
+ END IF;
+ IF NOT json_exists(p_report_json, '$.report') OR NOT json_exists(p_report_json, '$.rows') THEN
+ raise_application_error(-20102, 'Report payload requires report and rows.');
+ END IF;
+ SELECT html_template INTO l_template
+ FROM hmm_report_templates
+ WHERE template_key = 'hmm-carrier-performance'
+ AND active_yn = 'Y';
+ l_data := replace(p_report_json, '', '<\/');
+ l_html := dbms_lob.substr(replace(l_template, '__REPORT_DATA__', l_data), 32767, 1);
+ SELECT json_object(
+ 'status' VALUE 'ok',
+ 'template' VALUE 'hmm-carrier-performance',
+ 'html' VALUE l_html
+ RETURNING CLOB
+ ) INTO l_result FROM dual;
+ RETURN l_result;
+ EXCEPTION
+ WHEN no_data_found THEN
+ raise_application_error(-20103, 'Active report template is not installed.');
+ END render_carrier_report;
+END hmm_report_render_pkg;
+/
+
+BEGIN
+ DBMS_CLOUD_AI_AGENT.DROP_TOOL('HMM_CARRIER_REPORT_RENDERER', force => TRUE);
+ DBMS_CLOUD_AI_AGENT.CREATE_TOOL(
+ tool_name => 'HMM_CARRIER_REPORT_RENDERER',
+ attributes => q'~{
+ "instruction": "Render the supplied carrier-performance payload with the approved HMM HTML template. Do not query data and do not alter the supplied values.",
+ "function": "HMM_REPORT_RENDER_PKG.RENDER_CARRIER_REPORT",
+ "tool_inputs": [{"name":"P_REPORT_JSON","description":"Normalized carrier performance report JSON."}]
+ }~',
+ status => 'ENABLED',
+ description => 'Renders approved HMM carrier-performance HTML from already-authorized query results.'
+ );
+END;
+/
+
+SELECT tool_name, status
+ FROM user_ai_agent_tools
+ WHERE tool_name = 'HMM_CARRIER_REPORT_RENDERER';
diff --git a/deploy/vpd-backoffice/backoffice.env.example b/deploy/vpd-backoffice/backoffice.env.example
index 76848fb..d2f2f6b 100644
--- a/deploy/vpd-backoffice/backoffice.env.example
+++ b/deploy/vpd-backoffice/backoffice.env.example
@@ -11,7 +11,7 @@ BACKOFFICE_PRODUCT_DATA_LABEL='HMM HR 데이터'
BACKOFFICE_MCP_PUBLIC_URL='https://hmm-backoffice.cloud-handson.com/mcp'
BACKOFFICE_MCP_SERVER_NAME='hmm-hr-backoffice'
-BACKOFFICE_MCP_TOOLS='[{"name":"resolve_hr_term","label":"HMM HR 용어 표준화","description":"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.","argumentName":"term","argumentDescription":"확인할 휴가·근태 용어, 동의어 또는 코드입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_TERM_RESOLVER","targetParameterName":"P_TERM"},{"name":"search_hr_data","label":"HMM HR 데이터 조회","description":"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.","argumentName":"query","argumentDescription":"조직, 직원, 휴가 또는 근태에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_NORMALIZED_DATA_SEARCH","targetParameterName":"P_QUERY"},{"name":"search_hr_policy","label":"HMM HR 규정 검색","description":"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.","argumentName":"query","argumentDescription":"HR 규정에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_POLICY_SEARCH","targetParameterName":"P_QUERY"}]'
+BACKOFFICE_MCP_TOOLS='[{"name":"resolve_hr_term","label":"HMM HR 용어 표준화","description":"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.","argumentName":"term","argumentDescription":"확인할 휴가·근태 용어, 동의어 또는 코드입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_TERM_RESOLVER","targetParameterName":"P_TERM"},{"name":"search_hr_data","label":"HMM HR 데이터 조회","description":"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.","argumentName":"query","argumentDescription":"조직, 직원, 휴가 또는 근태에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_NORMALIZED_DATA_SEARCH","targetParameterName":"P_QUERY"},{"name":"search_hr_policy","label":"HMM HR 규정 검색","description":"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.","argumentName":"query","argumentDescription":"HR 규정에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_POLICY_SEARCH","targetParameterName":"P_QUERY"},{"name":"render_hmm_carrier_report","label":"HMM 선사 실적 HTML 리포트","description":"이미 권한이 적용된 조회 결과를 HMM HTML 리포트로 표현합니다. 데이터를 조회하거나 값을 변경하지 않는 후속 처리 전용 Tool입니다.","argumentName":"reportJson","argumentDescription":"앞 단계의 구조화된 조회 결과와 리포트 메타데이터를 담은 JSON입니다.","executionType":"AGENT_TOOL","targetName":"HMM_CARRIER_REPORT_RENDERER","targetParameterName":"P_REPORT_JSON"}]'
BACKOFFICE_MASKING_POLICIES='[{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"},{"objectName":"HMM_LEAVE_BALANCES","policyName":"HMM_LEAVE_BALANCE_REDACT"},{"objectName":"HMM_LEAVE_REQUESTS","policyName":"HMM_LEAVE_REQUEST_REDACT"},{"objectName":"HMM_ATTENDANCE_DAILY","policyName":"HMM_ATTENDANCE_REDACT"}]'
diff --git a/docs/design/hmm-html-report-mcp/README.md b/docs/design/hmm-html-report-mcp/README.md
new file mode 100644
index 0000000..8739a90
--- /dev/null
+++ b/docs/design/hmm-html-report-mcp/README.md
@@ -0,0 +1,39 @@
+# HMM HTML 리포트 MCP
+
+## 목적
+
+선사 실적 Federation 조회의 구조화 결과를 HMM 기업 리포트 HTML로 표현한다. 리포트 도구는
+DB를 다시 조회하거나 자연어를 해석하지 않는다.
+
+## 결정사항
+
+- MCP 도구 `render_hmm_carrier_report`는 `reportJson` 문자열 하나를 입력으로 받는다.
+- 입력은 질문·답변 근거·조회 행으로 구성된 허용 JSON 계약이며, Oracle DB 함수는 템플릿에만 매핑한다.
+- 선사 실적 조회 MCP가 VPD를 적용한 데이터 접근 경계이고, 리포트 MCP는 표현 경계다.
+- 승인된 HTML 템플릿은 DB CLOB으로 버전 관리하고, 생성 HTML은 MCP 응답의 `html` 속성으로만 반환한다.
+
+## 전체 흐름
+
+```text
+HMM 포털 → `https://hmm-backoffice.cloud-handson.com/mcp` → 데이터 조회 도구
+→ 포털의 범용 결과 정규화 → 이전 결과 입력형 렌더링 도구 → HTML 미리보기
+```
+
+리포트에 보이는 값은 조회 결과 행에서 계산되므로, 자연어 답변과 별개로 재해석되지 않는다.
+
+## 문서 지도
+
+- [아키텍처와 입력 계약](architecture.md)
+- [적용·검증 절차](cookbook.md)
+- [문제 해결](troubleshooting.md)
+
+## 현재 상태
+
+Oracle DB의 custom Agent Tool과 템플릿 CLOB은 적용됐다. 포털은 리포트·차트·HTML 요청에서
+도구 이름을 고정하지 않고, 발견한 도구의 설명·입력 스키마를 기준으로 데이터 조회 뒤 렌더링을
+순차 호출하도록 운영 배포한다.
+
+2026-08-10 운영 검증에서 `tools/list`, `reportJson` 입력, 64KB 입력 한도, 동적 JSON 매핑과
+Agent 2단계 실행을 확인했다. HTML 템플릿에는 업무 샘플 행을 저장하지 않으며, 호출 시 전달된
+`report`와 `rows`만 표시한다. 선행 조회가 0건을 반환하면 빈 리포트를 생성하는 것이 정상이며,
+조회 결과나 권한 설정은 이 기능의 변경 범위가 아니다.
diff --git a/docs/design/hmm-html-report-mcp/architecture.md b/docs/design/hmm-html-report-mcp/architecture.md
new file mode 100644
index 0000000..3d663f9
--- /dev/null
+++ b/docs/design/hmm-html-report-mcp/architecture.md
@@ -0,0 +1,72 @@
+# 아키텍처와 입력 계약
+
+## 책임 경계
+
+| 구성요소 | 책임 | DB 접근 |
+|---|---|---|
+| `search_carrier_performance` | Bearer 기반 사용자 식별 및 VPD 적용 선사 조회 | 허용 |
+| AI 웹 콘솔 | 도구 계약을 판별해 조회 결과를 정규화하고 렌더러를 후속 호출 | 없음 |
+| `render_hmm_carrier_report` | DB CLOB 템플릿에 허용된 JSON을 매핑 | 템플릿만 읽음 |
+| 브라우저 | 반환 HTML 표시·다운로드 | 없음 |
+
+```text
+HMM portal ──► /mcp (Bearer) ──► Backoffice MCP facade ──► DB Agent Tool
+ │
+ VPD applied rows
+ │
+ Console
+ │
+ report MCP tool call
+ │
+ HMM_REPORT_TEMPLATES
+ │
+ HTML 응답
+```
+
+리포트 MCP도 같은 Bearer 인증을 통과해야 하지만, 권한 판단은 조회 MCP에서 끝난다. 리포트
+입력의 임의 사용자 ID를 신뢰하거나 DB를 재조회하지 않는다.
+
+## 포털의 범용 순차 호출 규칙
+
+포털은 `render_hmm_carrier_report`라는 이름을 조건문에 넣지 않는다. 발견한 MCP 도구가 아래
+계약을 동시에 보이면 **이전 결과 입력형 렌더러**로 분류한다.
+
+- 입력 스키마에 `reportJson`·`report_payload`처럼 리포트 JSON/payload를 받는 문자열이 있다.
+- 설명에 HTML/리포트/렌더링 의도와 `조회 결과`, `후속 처리`, `already-authorized`처럼 선행 결과를
+ 사용한다는 의도가 있다.
+
+사용자가 리포트·보고서·대시보드·차트·HTML을 요청하고 이 렌더러가 발견되면, 기존 LLM 라우터는
+렌더러를 제외한 데이터 도구 중 하나를 먼저 선택한다. 첫 응답의 `items` 또는 `results`만
+camelCase JSON으로 정규화해 두 번째 도구에 전달한다. 포털은 렌더러의 `html` 응답을 iframe으로
+표시한다. 이 규칙은 동일 계약을 선언하는 다른 업무 리포트 도구에도 적용된다.
+
+## `reportJson` 계약
+
+최상위에는 `report` 객체와 `rows` 배열만 허용한다. `report`에는 `id`, `category`, `title`,
+`generatedAt`, `requestedBy`, `question`, `answer`, `execution`, `evidence`, `limitation`을
+넣는다. 행에는 담당자·선사 식별자와 최신 KPI만 넣는다.
+
+서버는 JSON 크기, 행 수, 문자열 길이, 숫자 형식을 제한하고, 템플릿에 주입할 JSON에서
+``를 이스케이프한다. payload는 저장하지 않는다.
+
+## 배포 도구 계약
+
+`BACKOFFICE_MCP_TOOLS`에 다음과 같이 등록한다. 실제 환경 변수에는 비밀값을 넣지 않는다.
+
+```json
+{
+ "name": "render_hmm_carrier_report",
+ "label": "HMM 선사 실적 HTML 리포트",
+ "description": "VPD 적용 선사 실적 조회 결과를 HMM HTML 리포트로 표현합니다.",
+ "argumentName": "reportJson",
+ "argumentDescription": "정규화된 선사 실적 리포트 JSON입니다.",
+ "executionType": "AGENT_TOOL",
+ "targetName": "HMM_CARRIER_REPORT_RENDERER",
+ "targetParameterName": "P_REPORT_JSON"
+}
+```
+
+`targetName`은 DBMS_CLOUD_AI_AGENT custom tool `HMM_CARRIER_REPORT_RENDERER`다.
+
+템플릿에는 `const reportData = __REPORT_DATA__;` 자리만 둔다. DB 함수는 호출 시 전달받은 JSON을
+이 자리에 삽입하고, 조회 Tool을 호출하거나 기본 업무 행을 보충하지 않는다.
diff --git a/docs/design/hmm-html-report-mcp/cookbook.md b/docs/design/hmm-html-report-mcp/cookbook.md
new file mode 100644
index 0000000..d4ef6c3
--- /dev/null
+++ b/docs/design/hmm-html-report-mcp/cookbook.md
@@ -0,0 +1,45 @@
+# 적용·검증 절차
+
+## 1. 준비
+
+- `vpd-backoffice` 배포본에 HTML 템플릿과 HMM CI 리소스가 포함되어야 한다.
+- 조회 도구 `search_carrier_performance`가 구조화된 `items` 배열을 반환해야 한다.
+- 배포 환경의 `BACKOFFICE_MCP_TOOLS`에 `AGENT_TOOL` 도구 계약을 추가한다.
+
+먼저 `database/adb/83_hmm_carrier_html_report_tool.sql`을 실행한 뒤 다음 명령으로 현재
+승인 템플릿을 CLOB에 적재한다. 스크립트는 템플릿을 UTF-8 Base64로 복원한다.
+
+```bash
+./scripts/load-hmm-carrier-report-template.sh
+```
+
+## 2. 확인
+
+1. 동일 Bearer token으로 `tools/list`를 호출한다.
+2. `render_hmm_carrier_report`와 입력 속성 `reportJson`이 보이는지 확인한다.
+3. 먼저 `search_carrier_performance`를 호출해 `items`를 얻는다.
+4. 질문·답변 근거·items를 reportJson으로 구성해 리포트 도구를 호출한다.
+5. 응답 `response.html`을 새 탭 또는 sandboxed iframe에서 연다.
+
+성공 판정은 제목, 담당자별 막대 차트, 위험 분포, 상세 표가 `rows`와 일치하는 것이다.
+템플릿 내부에 `__REPORT_DATA__`가 남지 않고, 검증 payload의 담당자·선사 식별자가 반환 HTML에
+포함되는지도 확인한다.
+
+## 3. 포털 순차 실행 확인
+
+1. 포털 MCP 설정의 endpoint를 `https://hmm-backoffice.cloud-handson.com/mcp`로 설정하고,
+ 허용 목록에 조회 도구와 리포트 렌더링 도구를 모두 넣는다.
+2. 포털에서 예를 들어 `E1001 팀의 선사 최신 실적을 HMM 리포트로 만들어줘`라고 요청한다.
+3. 실행 상세에서 첫 단계가 데이터 조회이고 두 번째 단계가 HTML 렌더링인지 확인한다.
+4. 결과 영역에 `생성된 리포트` iframe이 표시되고, 막대·상세 표가 첫 단계 `items`와 일치하는지
+ 확인한다.
+
+첫 단계가 0건이면 두 번째 단계의 `reportJson.rows`도 빈 배열이어야 한다. 이 경우 순차 실행과
+HTML 생성은 성공한 것이며, 데이터 조회 문제는 리포트 Tool과 분리해 확인한다.
+
+실패 시 [문제 해결](troubleshooting.md)의 `텍스트만 답하고 리포트가 생성되지 않음`을 따른다.
+
+## 4. 롤백
+
+`BACKOFFICE_MCP_TOOLS`에서 리포트 도구 항목을 제거하고 서비스를 재기동하면 기존 조회 MCP에는
+영향 없이 리포트 후속 호출만 중단된다. 템플릿은 DB나 사용자 데이터에 변경을 만들지 않는다.
diff --git a/docs/design/hmm-html-report-mcp/troubleshooting.md b/docs/design/hmm-html-report-mcp/troubleshooting.md
new file mode 100644
index 0000000..7531437
--- /dev/null
+++ b/docs/design/hmm-html-report-mcp/troubleshooting.md
@@ -0,0 +1,50 @@
+# 문제 해결
+
+## 텍스트만 답하고 리포트가 생성되지 않음
+
+**원인**: 포털이 단일 조회만 실행했거나, MCP discovery 결과에 이전 결과 입력형 렌더러가 없다.
+
+**확인**:
+
+1. 포털 설정 endpoint가 `https://hmm-backoffice.cloud-handson.com/mcp`인지 확인한다.
+2. `tools/list` 결과에 조회 도구와 `reportJson` 입력을 가진 HTML/리포트 렌더러가 모두 있는지 확인한다.
+3. 포털 실행 상세에서 실행 방식이 `agent`, Agent 단계가 두 번인지 확인한다.
+
+**해결**: endpoint와 허용 목록을 함께 갱신한 뒤 포털 서비스를 재기동한다. 렌더러 설명에는
+`조회 결과 후속 처리`처럼 선행 결과를 받는다는 문구를 유지한다.
+
+**재발 방지**: 새 업무 리포트 도구도 `reportJson`류 입력과 후속 처리 의도를 설명에 선언한다.
+포털 코드에 특정 도구명을 추가하지 않는다.
+
+## 렌더러가 입력 형식 오류를 반환함
+
+**원인**: 조회 도구가 `items`/`results` 배열을 반환하지 않거나, 렌더러가 요구하는 payload 계약과
+템플릿 계약이 다르다.
+
+**확인**: 첫 Agent 단계의 응답에 행 배열이 있는지, 두 번째 단계 arguments에 `report`와 `rows`가
+있는지 확인한다. 비밀값·Bearer token은 실행 상세에 남기지 않는다.
+
+**해결**: 조회 도구는 구조화 행 배열을 반환하고, 렌더러 함수는 `report`와 `rows` 계약을 유지한다.
+
+## 생성된 HTML이 화면에 표시되지 않음
+
+**원인**: 렌더러 응답에 `html` 문자열이 없거나, HTML이 유효하지 않다.
+
+**확인**: 두 번째 MCP 응답의 `response.html` 존재와 길이를 확인한다.
+
+**해결**: DB 템플릿 활성 상태와 custom Agent Tool의 반환 형식을 확인한다. 포털은 유효한 `html`
+또는 `rendered_html`만 sandboxed iframe으로 표시한다.
+
+## 리포트는 생성됐지만 행이 0건임
+
+**원인**: 렌더러 앞에서 실행된 데이터 조회 Tool이 0건을 반환했다. 렌더러는 없는 행을 만들거나
+기본 샘플을 채우지 않는다.
+
+**확인**: Agent 첫 단계의 결과 건수와 두 번째 단계 `reportJson.rows`를 비교한다. 둘 다 0건이면
+HTML 생성 경로는 정상이다.
+
+**해결**: 같은 사용자와 질문으로 데이터 조회 Tool만 별도 호출해 권한 범위와 조회 결과를
+확인한다. HTML 템플릿, renderer 함수 또는 Agent 순차 실행 규칙은 변경하지 않는다.
+
+**재발 방지**: HTML Tool 검증에는 별도의 합성 payload를 사용하고, 데이터 조회 검증과 판정을
+분리한다.
diff --git a/scripts/load-hmm-carrier-report-template.sh b/scripts/load-hmm-carrier-report-template.sh
new file mode 100755
index 0000000..b3f102b
--- /dev/null
+++ b/scripts/load-hmm-carrier-report-template.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# Load the approved UTF-8 HTML template into Oracle without SQLcl literal mojibake.
+set -Eeuo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+ENV_FILE="${HMM_REPORT_ENV_FILE:-$ROOT/.env}"
+TEMPLATE_FILE="${HMM_REPORT_TEMPLATE_FILE:-$ROOT/ai-web-agent-console/assets/hmm-carrier-performance-report.html}"
+WALLET_DIR="${HMM_REPORT_WALLET_DIR:-/Users/joungminko/devkit/db_conn/Wallet_HMMAIPOC}"
+[[ -f "$ENV_FILE" ]] && { set -a; . "$ENV_FILE"; set +a; }
+SQLCL_BIN="${SQLCL_BIN:-$(command -v sql || true)}"
+DB_USER="${BACKOFFICE_DB_USERNAME:-${ADB_USER:-}}"
+DB_PASSWORD="${BACKOFFICE_DB_PASSWORD:-${ADB_PASSWORD:-}}"
+DB_TNS="${ADB_TNS:-}"
+DB_URL="${BACKOFFICE_DB_URL:-}"
+if [[ -z "$DB_TNS" && "$DB_URL" =~ ^jdbc:oracle:thin:@([^?]+) ]]; then
+ DB_TNS="${BASH_REMATCH[1]}"
+fi
+[[ -x "$SQLCL_BIN" && -f "$TEMPLATE_FILE" && -d "$WALLET_DIR" && -n "$DB_USER" && -n "$DB_PASSWORD" && -n "$DB_TNS" ]] || {
+ echo "SQLCL_BIN, DB credentials, ADB_TNS, and template file are required." >&2; exit 1;
+}
+REPORT_B64="$(base64 < "$TEMPLATE_FILE" | tr -d '\n')"
+[[ ${#REPORT_B64} -le 30000 ]] || { echo "Template is too large for the single-chunk loader." >&2; exit 1; }
+"$SQLCL_BIN" -thin -tnsadmin "$WALLET_DIR" -s "$DB_USER/$DB_PASSWORD@$DB_TNS" <