"""
Dynamic Elicitation UI System
Production-ready, dynamic form rendering system for MCP elicitations.
Handles any elicitation type automatically without hardcoding.
Design Principles:
- DRY: One renderer for all elicitation types
- KISS: Simple, predictable API
- Dynamic: Handles any schema automatically
- Extensible: Easy to add new field types
- Production-ready: Full error handling and validation
"""
from typing import Dict, Any, List, Optional, Union
from dataclasses import dataclass
from enum import Enum
import json
import uuid
from datetime import datetime
from src.observability import get_logger
logger = get_logger(__name__)
class FieldType(str, Enum):
"""Supported field types for dynamic rendering."""
STRING = "string"
INTEGER = "integer"
NUMBER = "number"
BOOLEAN = "boolean"
ARRAY = "array"
EMAIL = "email"
URI = "uri"
DATE = "date"
DATE_TIME = "date-time"
@dataclass
class FieldConfig:
"""Configuration for rendering a form field."""
name: str
field_type: FieldType
label: str
description: Optional[str] = None
required: bool = False
default: Optional[Any] = None
placeholder: Optional[str] = None
options: Optional[List[Dict[str, Any]]] = None
validation: Optional[Dict[str, Any]] = None
ui_hints: Optional[Dict[str, Any]] = None
class DynamicFormRenderer:
"""
Dynamic form renderer that handles any MCP elicitation schema.
This renderer automatically generates appropriate UI components
based on JSON schema definitions without hardcoding specific
elicitation types.
"""
def __init__(self):
self.field_renderers = {
FieldType.STRING: self._render_string_field,
FieldType.INTEGER: self._render_integer_field,
FieldType.NUMBER: self._render_number_field,
FieldType.BOOLEAN: self._render_boolean_field,
FieldType.ARRAY: self._render_array_field,
FieldType.EMAIL: self._render_email_field,
FieldType.URI: self._render_uri_field,
FieldType.DATE: self._render_date_field,
FieldType.DATE_TIME: self._render_datetime_field,
}
# UI component templates
self.templates = {
"form_container": """
<div class="mcp-elicitation-form" data-elicitation-id="{elicitation_id}">
<div class="elicitation-header">
<h3>{title}</h3>
<p class="elicitation-description">{description}</p>
</div>
<div class="elicitation-body">
{fields}
</div>
<div class="elicitation-actions">
<button type="button" class="btn btn-primary" onclick="submitElicitation('{elicitation_id}')">
Submit
</button>
<button type="button" class="btn btn-secondary" onclick="declineElicitation('{elicitation_id}')">
Decline
</button>
<button type="button" class="btn btn-light" onclick="cancelElicitation('{elicitation_id}')">
Cancel
</button>
</div>
</div>
""",
"field_container": """
<div class="form-group {field_class}" data-field-name="{field_name}">
<label for="{field_id}" class="form-label">
{label}
{required_indicator}
</label>
{description}
{field_input}
{validation_message}
</div>
""",
"description": """
<small class="form-text text-muted">{description}</small>
""",
"required_indicator": """
<span class="text-danger">*</span>
""",
"validation_message": """
<div class="invalid-feedback" style="display: none;"></div>
"""
}
def render_elicitation_form(
self,
elicitation_request: Dict[str, Any],
elicitation_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Render a complete elicitation form dynamically.
Args:
elicitation_request: MCP elicitation/create request
elicitation_id: Unique ID for this elicitation instance
Returns:
Dict containing rendered form and metadata
"""
try:
if not elicitation_id:
elicitation_id = str(uuid.uuid4())
logger.info(
"rendering_elicitation_form",
elicitation_id=elicitation_id,
mode=elicitation_request.get("params", {}).get("mode"),
message=elicitation_request.get("params", {}).get("message", "")[:50]
)
params = elicitation_request.get("params", {})
mode = params.get("mode", "form")
if mode != "form":
return self._render_url_mode_elicitation(params, elicitation_id)
# Extract schema and build field configurations
schema = params.get("requestedSchema", {})
field_configs = self._build_field_configs(schema)
# Render all fields
rendered_fields = []
for config in field_configs:
field_html = self._render_field(config, elicitation_id)
rendered_fields.append(field_html)
# Build complete form
form_html = self.templates["form_container"].format(
elicitation_id=elicitation_id,
title=params.get("message", "Please provide information"),
description=self._get_form_description(schema),
fields="".join(rendered_fields)
)
# Build JavaScript for form handling
js_code = self._generate_form_js(field_configs, elicitation_id)
# Build CSS for form styling
css_code = self._generate_form_css()
result = {
"elicitation_id": elicitation_id,
"mode": mode,
"form_html": form_html,
"javascript": js_code,
"css": css_code,
"field_configs": [self._serialize_config(config) for config in field_configs],
"metadata": {
"rendered_at": datetime.utcnow().isoformat(),
"field_count": len(field_configs),
"required_fields": len([c for c in field_configs if c.required])
}
}
logger.info(
"elicitation_form_rendered",
elicitation_id=elicitation_id,
field_count=len(field_configs),
required_fields=result["metadata"]["required_fields"]
)
return result
except Exception as e:
logger.error(
"failed_to_render_elicitation_form",
error=str(e),
error_type=type(e).__name__,
elicitation_id=elicitation_id
)
return self._render_error_form(str(e), elicitation_id)
def _build_field_configs(self, schema: Dict[str, Any]) -> List[FieldConfig]:
"""Build field configurations from JSON schema."""
configs = []
properties = schema.get("properties", {})
required_fields = schema.get("required", [])
for field_name, field_schema in properties.items():
config = self._parse_field_schema(field_name, field_schema, required_fields)
configs.append(config)
return configs
def _parse_field_schema(
self,
field_name: str,
field_schema: Dict[str, Any],
required_fields: List[str]
) -> FieldConfig:
"""Parse individual field schema into FieldConfig."""
# Determine field type
field_type = self._determine_field_type(field_schema)
# Extract label
label = field_schema.get("title", self._format_field_name(field_name))
# Extract other properties
description = field_schema.get("description")
required = field_name in required_fields
default = field_schema.get("default")
placeholder = field_schema.get("placeholder")
# Extract validation rules
validation = {}
if field_type in [FieldType.STRING, FieldType.EMAIL, FieldType.URI]:
if "minLength" in field_schema:
validation["min_length"] = field_schema["minLength"]
if "maxLength" in field_schema:
validation["max_length"] = field_schema["maxLength"]
if "pattern" in field_schema:
validation["pattern"] = field_schema["pattern"]
if field_type in [FieldType.INTEGER, FieldType.NUMBER]:
if "minimum" in field_schema:
validation["min"] = field_schema["minimum"]
if "maximum" in field_schema:
validation["max"] = field_schema["maximum"]
# Extract enum options
options = None
if "enum" in field_schema:
options = [{"value": opt, "label": str(opt)} for opt in field_schema["enum"]]
elif "oneOf" in field_schema:
options = [
{"value": opt.get("const"), "label": opt.get("title", str(opt.get("const")))}
for opt in field_schema["oneOf"]
]
elif "anyOf" in field_schema:
options = [
{"value": opt.get("const"), "label": opt.get("title", str(opt.get("const")))}
for opt in field_schema["anyOf"]
]
# UI hints for better rendering
ui_hints = field_schema.get("uiHints", {})
return FieldConfig(
name=field_name,
field_type=field_type,
label=label,
description=description,
required=required,
default=default,
placeholder=placeholder,
options=options,
validation=validation,
ui_hints=ui_hints
)
def _determine_field_type(self, field_schema: Dict[str, Any]) -> FieldType:
"""Determine field type from schema."""
schema_type = field_schema.get("type", "string")
format_type = field_schema.get("format")
# Handle array types (multi-select enums)
if schema_type == "array":
items = field_schema.get("items", {})
if "enum" in items or "oneOf" in items or "anyOf" in items:
return FieldType.ARRAY
# Handle format-based types
if format_type == "email":
return FieldType.EMAIL
elif format_type == "uri":
return FieldType.URI
elif format_type == "date":
return FieldType.DATE
elif format_type == "date-time":
return FieldType.DATE_TIME
# Handle basic types
if schema_type == "integer":
return FieldType.INTEGER
elif schema_type == "number":
return FieldType.NUMBER
elif schema_type == "boolean":
return FieldType.BOOLEAN
else:
return FieldType.STRING
def _render_field(self, config: FieldConfig, elicitation_id: str) -> str:
"""Render individual form field."""
renderer = self.field_renderers.get(config.field_type, self._render_string_field)
field_input = renderer(config, elicitation_id)
required_indicator = self.templates["required_indicator"] if config.required else ""
description = self.templates["description"].format(description=config.description) if config.description else ""
validation_message = self.templates["validation_message"]
field_class = f"field-{config.field_type.value}"
if config.required:
field_class += " required"
return self.templates["field_container"].format(
field_class=field_class,
field_name=config.name,
field_id=f"{elicitation_id}_{config.name}",
label=config.label,
required_indicator=required_indicator,
description=description,
field_input=field_input,
validation_message=validation_message
)
def _render_string_field(self, config: FieldConfig, elicitation_id: str) -> str:
"""Render string input field."""
attrs = [
f'type="text"',
f'id="{elicitation_id}_{config.name}"',
f'name="{config.name}"',
f'class="form-control"',
]
if config.placeholder:
attrs.append(f'placeholder="{config.placeholder}"')
if config.required:
attrs.append('required')
if config.default:
attrs.append(f'value="{config.default}"')
# Add validation attributes
if config.validation:
if "min_length" in config.validation:
attrs.append(f'minlength="{config.validation["min_length"]}"')
if "max_length" in config.validation:
attrs.append(f'maxlength="{config.validation["max_length"]}"')
if "pattern" in config.validation:
attrs.append(f'pattern="{config.validation["pattern"]}"')
return f'<input {" ".join(attrs)} />'
def _render_email_field(self, config: FieldConfig, elicitation_id: str) -> str:
"""Render email input field."""
attrs = [
f'type="email"',
f'id="{elicitation_id}_{config.name}"',
f'name="{config.name}"',
f'class="form-control"',
]
if config.placeholder:
attrs.append(f'placeholder="{config.placeholder}"')
if config.required:
attrs.append('required')
if config.default:
attrs.append(f'value="{config.default}"')
return f'<input {" ".join(attrs)} />'
def _render_uri_field(self, config: FieldConfig, elicitation_id: str) -> str:
"""Render URL input field."""
attrs = [
f'type="url"',
f'id="{elicitation_id}_{config.name}"',
f'name="{config.name}"',
f'class="form-control"',
]
if config.placeholder:
attrs.append(f'placeholder="{config.placeholder}"')
if config.required:
attrs.append('required')
if config.default:
attrs.append(f'value="{config.default}"')
return f'<input {" ".join(attrs)} />'
def _render_integer_field(self, config: FieldConfig, elicitation_id: str) -> str:
"""Render integer input field."""
attrs = [
f'type="number"',
f'id="{elicitation_id}_{config.name}"',
f'name="{config.name}"',
f'class="form-control"',
'step="1"'
]
if config.placeholder:
attrs.append(f'placeholder="{config.placeholder}"')
if config.required:
attrs.append('required')
if config.default is not None:
attrs.append(f'value="{config.default}"')
# Add validation attributes
if config.validation:
if "min" in config.validation:
attrs.append(f'min="{config.validation["min"]}"')
if "max" in config.validation:
attrs.append(f'max="{config.validation["max"]}"')
return f'<input {" ".join(attrs)} />'
def _render_number_field(self, config: FieldConfig, elicitation_id: str) -> str:
"""Render number input field."""
attrs = [
f'type="number"',
f'id="{elicitation_id}_{config.name}"',
f'name="{config.name}"',
f'class="form-control"',
'step="any"'
]
if config.placeholder:
attrs.append(f'placeholder="{config.placeholder}"')
if config.required:
attrs.append('required')
if config.default is not None:
attrs.append(f'value="{config.default}"')
# Add validation attributes
if config.validation:
if "min" in config.validation:
attrs.append(f'min="{config.validation["min"]}"')
if "max" in config.validation:
attrs.append(f'max="{config.validation["max"]}"')
return f'<input {" ".join(attrs)} />'
def _render_boolean_field(self, config: FieldConfig, elicitation_id: str) -> str:
"""Render boolean checkbox field."""
attrs = [
f'type="checkbox"',
f'id="{elicitation_id}_{config.name}"',
f'name="{config.name}"',
f'class="form-check-input"',
f'value="true"'
]
if config.default:
attrs.append('checked')
checkbox = f'<input {" ".join(attrs)} />'
label = f'<label class="form-check-label" for="{elicitation_id}_{config.name}">{config.label}</label>'
return f'<div class="form-check">{checkbox}{label}</div>'
def _render_array_field(self, config: FieldConfig, elicitation_id: str) -> str:
"""Render multi-select field for arrays."""
if not config.options:
# Fallback to text input for arrays without options
return self._render_string_field(config, elicitation_id)
attrs = [
f'id="{elicitation_id}_{config.name}"',
f'name="{config.name}"',
f'class="form-control"',
'multiple'
]
options_html = ""
for option in config.options:
selected = ""
if config.default and option["value"] in config.default:
selected = "selected"
options_html += f'<option value="{option["value"]}" {selected}>{option["label"]}</option>'
return f'<select {" ".join(attrs)}>{options_html}</select>'
def _render_date_field(self, config: FieldConfig, elicitation_id: str) -> str:
"""Render date input field."""
attrs = [
f'type="date"',
f'id="{elicitation_id}_{config.name}"',
f'name="{config.name}"',
f'class="form-control"',
]
if config.required:
attrs.append('required')
if config.default:
attrs.append(f'value="{config.default}"')
return f'<input {" ".join(attrs)} />'
def _render_datetime_field(self, config: FieldConfig, elicitation_id: str) -> str:
"""Render datetime input field."""
attrs = [
f'type="datetime-local"',
f'id="{elicitation_id}_{config.name}"',
f'name="{config.name}"',
f'class="form-control"',
]
if config.required:
attrs.append('required')
if config.default:
attrs.append(f'value="{config.default}"')
return f'<input {" ".join(attrs)} />'
def _render_url_mode_elicitation(self, params: Dict[str, Any], elicitation_id: str) -> Dict[str, Any]:
"""Render URL mode elicitation (redirect to external URL)."""
url = params.get("url")
message = params.get("message", "Please complete the required action.")
html = f"""
<div class="mcp-elicitation-url" data-elicitation-id="{elicitation_id}">
<div class="alert alert-info">
<h4>External Action Required</h4>
<p>{message}</p>
<p><strong>You will be redirected to:</strong> <code>{url}</code></p>
<div class="mt-3">
<button type="button" class="btn btn-primary" onclick="openElicitationUrl('{elicitation_id}', '{url}')">
Continue to External Site
</button>
<button type="button" class="btn btn-secondary" onclick="declineElicitation('{elicitation_id}')">
Decline
</button>
</div>
</div>
</div>
"""
js_code = f"""
function openElicitationUrl(elicitationId, url) {{
console.log('Opening URL for elicitation:', elicitationId, url);
window.open(url, '_blank', 'noopener,noreferrer');
submitElicitation(elicitationId, {{}});
}}
"""
return {
"elicitation_id": elicitation_id,
"mode": "url",
"form_html": html,
"javascript": js_code,
"css": "",
"field_configs": [],
"metadata": {
"rendered_at": datetime.utcnow().isoformat(),
"redirect_url": url,
"field_count": 0
}
}
def _render_error_form(self, error_message: str, elicitation_id: str) -> Dict[str, Any]:
"""Render error form when rendering fails."""
html = f"""
<div class="mcp-elicitation-error" data-elicitation-id="{elicitation_id}">
<div class="alert alert-danger">
<h4>Form Rendering Error</h4>
<p>Unable to render the requested form:</p>
<pre>{error_message}</pre>
<button type="button" class="btn btn-secondary" onclick="cancelElicitation('{elicitation_id}')">
Close
</button>
</div>
</div>
"""
return {
"elicitation_id": elicitation_id,
"mode": "error",
"form_html": html,
"javascript": "",
"css": "",
"field_configs": [],
"metadata": {
"rendered_at": datetime.utcnow().isoformat(),
"error": error_message,
"field_count": 0
}
}
def _generate_form_js(self, field_configs: List[FieldConfig], elicitation_id: str) -> str:
"""Generate JavaScript for form handling."""
validation_rules = []
for config in field_configs:
rules = []
if config.required:
rules.append("required: true")
if config.validation:
if "min_length" in config.validation:
rules.append(f'minlength: {config.validation["min_length"]}')
if "max_length" in config.validation:
rules.append(f'maxlength: {config.validation["max_length"]}')
if "min" in config.validation:
rules.append(f'min: {config.validation["min"]}')
if "max" in config.validation:
rules.append(f'max: {config.validation["max"]}')
if "pattern" in config.validation:
rules.append(f'pattern: /{config.validation["pattern"]}/')
if rules:
validation_rules.append(f'"{config.name}": {{{", ".join(rules)}}}')
validation_js = ""
if validation_rules:
validation_js = f"""
const validationRules = {{
{", ".join(validation_rules)}
}};
// Add validation logic here
"""
return f"""
// Elicitation form handling for {elicitation_id}
(function() {{
{validation_js}
window.submitElicitation = function(elicitationId) {{
const form = document.querySelector('[data-elicitation-id="' + elicitationId + '"]');
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
// Handle checkboxes
form.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {{
data[checkbox.name] = checkbox.checked;
}});
// Handle multi-selects
form.querySelectorAll('select[multiple]').forEach(select => {{
data[select.name] = Array.from(select.selectedOptions).map(option => option.value);
}});
console.log('Submitting elicitation:', elicitationId, data);
// Send to parent window or handler
if (window.parent && window.parent.handleElicitationResponse) {{
window.parent.handleElicitationResponse(elicitationId, 'accept', data);
}} else if (window.handleElicitationResponse) {{
window.handleElicitationResponse(elicitationId, 'accept', data);
}}
}};
window.declineElicitation = function(elicitationId) {{
console.log('Declining elicitation:', elicitationId);
if (window.parent && window.parent.handleElicitationResponse) {{
window.parent.handleElicitationResponse(elicitationId, 'decline', null);
}} else if (window.handleElicitationResponse) {{
window.handleElicitationResponse(elicitationId, 'decline', null);
}}
}};
window.cancelElicitation = function(elicitationId) {{
console.log('Cancelling elicitation:', elicitationId);
if (window.parent && window.parent.handleElicitationResponse) {{
window.parent.handleElicitationResponse(elicitationId, 'cancel', null);
}} else if (window.handleElicitationResponse) {{
window.handleElicitationResponse(elicitationId, 'cancel', null);
}}
}};
}})();
"""
def _generate_form_css(self) -> str:
"""Generate CSS for form styling."""
return """
.mcp-elicitation-form {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
margin: 1rem 0;
background: white;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.elicitation-header {
padding: 1rem 1rem 0 1rem;
border-bottom: 1px solid #dee2e6;
}
.elicitation-header h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
color: #495057;
}
.elicitation-description {
color: #6c757d;
margin: 0;
}
.elicitation-body {
padding: 1rem;
}
.elicitation-actions {
padding: 1rem;
border-top: 1px solid #dee2e6;
background: #f8f9fa;
display: flex;
gap: 0.5rem;
}
.form-group.required .form-label::after {
content: " *";
color: #dc3545;
}
.invalid-feedback {
display: block;
width: 100%;
margin-top: 0.25rem;
font-size: 0.875em;
color: #dc3545;
}
.mcp-elicitation-url {
margin: 1rem 0;
}
.mcp-elicitation-error {
margin: 1rem 0;
}
"""
def _get_form_description(self, schema: Dict[str, Any]) -> str:
"""Extract description from schema."""
if "description" in schema:
return schema["description"]
# Generate description from required fields
required = schema.get("required", [])
if required:
return f"Please provide the following required information: {', '.join(required)}."
return "Please provide the requested information."
def _format_field_name(self, field_name: str) -> str:
"""Format field name into readable label."""
return field_name.replace("_", " ").title()
def _serialize_config(self, config: FieldConfig) -> Dict[str, Any]:
"""Serialize FieldConfig for JSON response."""
return {
"name": config.name,
"field_type": config.field_type.value,
"label": config.label,
"description": config.description,
"required": config.required,
"default": config.default,
"placeholder": config.placeholder,
"options": config.options,
"validation": config.validation,
"ui_hints": config.ui_hints
}
# Global instance for easy access
form_renderer = DynamicFormRenderer()