From 8a7d4bca7d0946b7cafb31de67e978bd3c18e6c8 Mon Sep 17 00:00:00 2001 From: Jose Miguel Izquierdo Date: Thu, 23 Apr 2026 11:45:12 +0200 Subject: [PATCH 1/4] Fix: render_and_apply_j2_template auto-detect config format instead of hardcoding "set" Co-authored-by: Copilot --- jmcp.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/jmcp.py b/jmcp.py index e96c3f8..64c0e0c 100644 --- a/jmcp.py +++ b/jmcp.py @@ -1487,11 +1487,27 @@ async def handle_render_and_apply_j2_template( dev.open() await context.info(f"Connected to {rtr_name}") + # Detect config format: "set" if all non-empty lines + # start with set/delete/deactivate/activate, otherwise "text" + 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 + # Load configuration using exclusive mode 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() From a8d927969d48039c566e7b1940e0895091d6710b Mon Sep 17 00:00:00 2001 From: Jose Miguel Izquierdo Date: Thu, 23 Apr 2026 14:01:13 +0200 Subject: [PATCH 2/4] feat(jmcp): add explicit config_format parameter to render_and_apply_j2_template --- jmcp.py | 116 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 60 insertions(+), 56 deletions(-) diff --git a/jmcp.py b/jmcp.py index 64c0e0c..01c51d2 100644 --- a/jmcp.py +++ b/jmcp.py @@ -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: @@ -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( @@ -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) @@ -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...") @@ -1420,18 +1429,16 @@ 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", @@ -1443,7 +1450,6 @@ async def handle_render_and_apply_j2_template( ) ] - # Step 5: Apply configuration to specified routers if not router_names: return [ types.TextContent( @@ -1472,7 +1478,6 @@ 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: @@ -1480,27 +1485,32 @@ async def handle_render_and_apply_j2_template( 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}") - # Detect config format: "set" if all non-empty lines - # start with set/delete/deactivate/activate, otherwise "text" - 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 + 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}" + ) - # Load configuration using exclusive mode try: with Config(dev, mode="exclusive") as cu: await context.info( @@ -1509,7 +1519,6 @@ async def handle_render_and_apply_j2_template( ) cu.load(rendered_config, format=config_format) - # Get diff diff = cu.diff() if not diff: @@ -1518,8 +1527,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}..." ) @@ -1555,17 +1562,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: @@ -1588,7 +1589,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}..." ) @@ -1604,7 +1604,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}..." ) @@ -1630,7 +1629,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}") @@ -1646,23 +1644,22 @@ 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( @@ -1677,7 +1674,6 @@ async def handle_render_and_apply_j2_template( ) ] - async def handle_gather_device_facts( arguments: dict, context: Context ) -> list[types.ContentBlock]: @@ -2217,6 +2213,14 @@ async def list_tools() -> list[types.Tool]: "description": "Commit comment", "default": "Configuration loaded via MCP", }, + "config_format": { + "type": "string", + "description": ( + "Configuration format: 'set', 'text' (stanza), or 'xml'. " + "If omitted, auto-detected from the rendered template content." + ), + "enum": ["set", "text", "xml"], + }, }, "required": ["template_content", "vars_content"], }, From abcd8f45ea897e4c98322ac70eb64bffacc9ffb0 Mon Sep 17 00:00:00 2001 From: Jose Miguel Izquierdo Date: Fri, 24 Apr 2026 15:18:55 +0200 Subject: [PATCH 3/4] fix(jmcp): fix render_and_apply_j2_template tool description and schema --- jmcp.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/jmcp.py b/jmcp.py index 01c51d2..c80947d 100644 --- a/jmcp.py +++ b/jmcp.py @@ -2181,43 +2181,78 @@ 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', 'text' (stanza), or 'xml'. " - "If omitted, auto-detected from the rendered template content." + "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"], }, @@ -2506,3 +2541,4 @@ async def lifespan(app): if __name__ == "__main__": main() + From bf23cfb13d85228cd39550f5dbafcd60f2123c81 Mon Sep 17 00:00:00 2001 From: Jose Miguel Izquierdo Date: Fri, 24 Apr 2026 15:18:55 +0200 Subject: [PATCH 4/4] fix(jmcp): fix render_and_apply_j2_template tool description and schema --- .github/workflows/ci.yml | 2 +- jmcp.py | 22 ++++++---------------- pyproject.toml | 3 +++ 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43b0dfa..043b857 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/jmcp.py b/jmcp.py index c80947d..7481ad8 100644 --- a/jmcp.py +++ b/jmcp.py @@ -1433,9 +1433,7 @@ async def handle_render_and_apply_j2_template( result_text = ( "✅ Template rendered successfully!\n\n" "**Rendered Configuration:**\n" - "```\n" - + rendered_config - + "\n```\n\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" ) @@ -1493,9 +1491,7 @@ async def handle_render_and_apply_j2_template( if config_format_override: config_format = config_format_override - await context.info( - f"Using explicit config format: {config_format}" - ) + await context.info(f"Using explicit config format: {config_format}") else: config_format = "set" for line in rendered_config.strip().splitlines(): @@ -1507,9 +1503,7 @@ async def handle_render_and_apply_j2_template( ): config_format = "text" break - await context.info( - f"Auto-detected config format: {config_format}" - ) + await context.info(f"Auto-detected config format: {config_format}") try: with Config(dev, mode="exclusive") as cu: @@ -1653,12 +1647,8 @@ async def handle_render_and_apply_j2_template( 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" + "```\n" + rendered_config + "\n```\n\n" + "**Results:**\n" + summary + "\n" ) return [ @@ -1674,6 +1664,7 @@ async def handle_render_and_apply_j2_template( ) ] + async def handle_gather_device_facts( arguments: dict, context: Context ) -> list[types.ContentBlock]: @@ -2541,4 +2532,3 @@ async def lifespan(app): if __name__ == "__main__": main() - diff --git a/pyproject.toml b/pyproject.toml index a7837f5..d0b0a79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]