Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
- name: Run Code Quality Tools
if: matrix.os == 'ubuntu-latest'
run: |
black --check .
black --check --fast .
pylint $(git ls-files '*.py' | grep -vE '^(docs/|build/|tests/|samples/|setup.py|versioneer.py)') --exit-zero

- name: Run Unit Tests
Expand Down
154 changes: 100 additions & 54 deletions jmcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -1343,6 +1343,7 @@ async def handle_render_and_apply_j2_template(
- apply_config: Boolean to apply or just render (default: False)
- commit_comment: Optional commit comment
- dry_run: Boolean to show diff without committing (default: False)
- config_format: Override format detection ('set', 'text', 'xml'). Auto-detected if omitted.
context: MCP Context object

Returns:
Expand All @@ -1357,10 +1358,10 @@ async def handle_render_and_apply_j2_template(
"commit_comment", "Configuration applied via Jinja2 template"
)
dry_run = arguments.get("dry_run", False)
config_format_override = arguments.get("config_format", None)

results = []

# Step 1: Validate inputs
if not template_content:
return [
types.TextContent(
Expand All @@ -1373,11 +1374,20 @@ async def handle_render_and_apply_j2_template(
types.TextContent(type="text", text="❌ Error: vars_content is required")
]

# Handle single router_name or list of router_names
if config_format_override and config_format_override not in ("set", "text", "xml"):
return [
types.TextContent(
type="text",
text=(
f"❌ Error: invalid config_format '{config_format_override}'. "
"Must be 'set', 'text', or 'xml'."
),
)
]

if router_name and not router_names:
router_names = [router_name]

# Step 2: Load variables from YAML string
try:
await context.info("Parsing variables from YAML content...")
variables = yaml.safe_load(vars_content)
Expand All @@ -1398,7 +1408,6 @@ async def handle_render_and_apply_j2_template(
except Exception as e:
return [types.TextContent(type="text", text=f"❌ Error loading variables: {e}")]

# Step 3: Setup Jinja2 environment and render template
try:
await context.info("Rendering Jinja2 template...")

Expand All @@ -1420,18 +1429,14 @@ async def handle_render_and_apply_j2_template(
)
]

# Step 4: If not applying, just return the rendered config
if not apply_config:
result_text = f"""✅ Template rendered successfully!

**Rendered Configuration:**
```
{rendered_config}
```

To apply this configuration to devices, set apply_config=true and provide
router_name or router_names.
"""
result_text = (
"✅ Template rendered successfully!\n\n"
"**Rendered Configuration:**\n"
"```\n" + rendered_config + "\n```\n\n"
"To apply this configuration to devices, set apply_config=true and provide "
"router_name or router_names.\n"
)
return [
types.TextContent(
type="text",
Expand All @@ -1443,7 +1448,6 @@ async def handle_render_and_apply_j2_template(
)
]

# Step 5: Apply configuration to specified routers
if not router_names:
return [
types.TextContent(
Expand Down Expand Up @@ -1472,28 +1476,43 @@ async def handle_render_and_apply_j2_template(

device_info = devices[rtr_name]

# Use prepare_connection_params to get proper connection parameters
try:
connect_params = prepare_connection_params(device_info, rtr_name)
except ValueError as ve:
application_results.append(f"❌ {rtr_name}: {ve}")
await context.error(f"{rtr_name}: {ve}")
continue

# Connect to device
dev = Device(**connect_params)

try:
dev.open()
await context.info(f"Connected to {rtr_name}")

# Load configuration using exclusive mode
if config_format_override:
config_format = config_format_override
await context.info(f"Using explicit config format: {config_format}")
else:
config_format = "set"
for line in rendered_config.strip().splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if not re.match(
r"^(set|delete|deactivate|activate)\s", stripped
):
config_format = "text"
break
await context.info(f"Auto-detected config format: {config_format}")

try:
with Config(dev, mode="exclusive") as cu:
await context.info(f"Loading configuration on {rtr_name}...")
cu.load(rendered_config, format="set")
await context.info(
f"Loading configuration on {rtr_name} "
f"(format={config_format})..."
)
cu.load(rendered_config, format=config_format)

# Get diff
diff = cu.diff()

if not diff:
Expand All @@ -1502,8 +1521,6 @@ async def handle_render_and_apply_j2_template(
await context.info(f"{rtr_name}: {result_msg}")
else:
if dry_run:
# DRY RUN: Perform commit check, show diff,
# and rollback without committing
await context.info(
f"Performing commit check on {rtr_name}..."
)
Expand Down Expand Up @@ -1539,17 +1556,11 @@ async def handle_render_and_apply_j2_template(
)
await context.error(f"{rtr_name}: {result_msg}")
finally:
# CRITICAL: Always rollback in dry-run mode
await context.info(
f"{rtr_name}: Rolling back changes (dry-run mode)"
)
try:
# Perform the rollback
cu.rollback()
# Verify rollback success by checking
# if there are pending changes.
# After a successful rollback, there
# should be no differences.
diff = cu.diff()

if diff:
Expand All @@ -1572,7 +1583,6 @@ async def handle_render_and_apply_j2_template(
f"error: {str(rollback_error)}"
)
else:
# REAL COMMIT: Perform commit check before committing
await context.info(
f"Performing commit check on {rtr_name}..."
)
Expand All @@ -1588,7 +1598,6 @@ async def handle_render_and_apply_j2_template(
await context.error(f"{rtr_name}: {result_msg}")
cu.rollback()
else:
# Apply the changes
await context.info(
f"Committing configuration on {rtr_name}..."
)
Expand All @@ -1614,7 +1623,6 @@ async def handle_render_and_apply_j2_template(
application_results.append(f"❌ {rtr_name}: {error_msg}")
await context.error(f"{rtr_name}: {error_msg}")
finally:
# Always close the device connection
try:
dev.close()
await context.info(f"Disconnected from {rtr_name}")
Expand All @@ -1630,23 +1638,18 @@ async def handle_render_and_apply_j2_template(
application_results.append(f"❌ {rtr_name}: {error_msg}")
await context.error(f"{rtr_name}: {error_msg}")

# Step 6: Format final results
summary = "\n".join(application_results)

mode_prefix = "🔍 DRY RUN - " if dry_run else ""
mode_name = "preview" if dry_run else "application"
final_text = f"""{mode_prefix}Configuration {mode_name} complete!

**Routers:** {', '.join(router_names)}

**Rendered Configuration:**
```
{rendered_config}
```

**Results:**
{summary}
"""
final_text = (
mode_prefix + "Configuration " + mode_name + " complete!\n\n"
"**Routers:** " + ", ".join(router_names) + "\n\n"
"**Rendered Configuration:**\n"
"```\n" + rendered_config + "\n```\n\n"
"**Results:**\n" + summary + "\n"
)

return [
types.TextContent(
Expand Down Expand Up @@ -2169,37 +2172,80 @@ async def list_tools() -> list[types.Tool]:
),
types.Tool(
name="render_and_apply_j2_template",
description="Render a Jinja2 template and apply it to the router",
description=(
"Render a Jinja2 template with YAML variables and optionally apply it "
"to one or more Junos routers. "
"When apply_config=false (default), the template is only rendered locally "
"— no device connection is made. "
"When apply_config=true, the tool connects to the device(s) and loads the "
"rendered configuration. "
"Combine apply_config=true with dry_run=true to perform a commit check "
"on the device and display the diff without committing — changes are "
"automatically rolled back after the check. "
"Use router_name for a single device or router_names (list) for multiple "
"devices; at least one must be provided when apply_config=true."
),
inputSchema={
"type": "object",
"properties": {
"router_name": {
"type": "string",
"description": "The name of the router",
"description": (
"Name of a single router to apply the configuration to. "
"Required when apply_config=true and router_names is not provided."
),
},
"router_names": {
"type": "array",
"items": {"type": "string"},
"description": (
"JSON array of router name strings to apply the configuration to "
"in sequence, e.g. ['pe1', 'pe2', 'pe3']. "
"Each element must exactly match a name in the device mapping. "
"Use this instead of router_name when targeting multiple devices. "
"Required when apply_config=true and router_name is not provided."
),
},
"template_content": {
"type": "string",
"description": "Jinja2 template to load",
"description": "Jinja2 template content as a string.",
},
"vars_content": {
"type": "string",
"description": "YAML variables to load",
"description": "YAML-formatted variables to render into the template.",
},
"apply_config": {
"type": "boolean",
"description": "Boolean to apply or just render (default: False)",
"description": (
"If false (default), only render the template locally without "
"connecting to any device. If true, connect to the device(s) "
"and load the rendered configuration."
),
},
"dry_run": {
"type": "boolean",
"description": (
"Boolean to show diff without committing "
"(default: False)"
"Only effective when apply_config=true. If true, perform a "
"commit check on the device and show the diff without committing. "
"Changes are automatically rolled back after the check. "
"If false (default), commit the configuration."
),
},
"commit_comment": {
"type": "string",
"description": "Commit comment",
"default": "Configuration loaded via MCP",
"description": "Commit comment recorded in the device commit log.",
"default": "Configuration applied via Jinja2 template",
},
"config_format": {
"type": "string",
"description": (
"Configuration format: 'set' (flat set commands), "
"'text' (stanza/hierarchical), or 'xml'. "
"If omitted, auto-detected from the rendered template content: "
"lines starting with set/delete/deactivate/activate → 'set', "
"otherwise → 'text'."
),
"enum": ["set", "text", "xml"],
},
},
"required": ["template_content", "vars_content"],
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ dependencies = [
[project.scripts]
junos-mcp-server = "jmcp:main"

[tool.black]
target-version = ["py310"]

[tool.setuptools]
py-modules = ["jmcp", "jmcp_token_manager"]

Expand Down
Loading