Skip to content

fix: Network/VLAN information not displayed for clients#60

Open
smarba wants to merge 2 commits into
bjeans:mainfrom
smarba:fix/vlan-information-display
Open

fix: Network/VLAN information not displayed for clients#60
smarba wants to merge 2 commits into
bjeans:mainfrom
smarba:fix/vlan-information-display

Conversation

@smarba

@smarba smarba commented Feb 12, 2026

Copy link
Copy Markdown

Pull Request: Fix Network/VLAN Information Not Displayed for Clients

Summary

All network clients show as "Unknown (VLAN N/A)" even though network and VLAN data exists. This is caused by a field name mismatch between the exporter output format and the MCP wrapper code expectations.

Issue Description

Current Behavior

  • All clients display as "Unknown (VLAN N/A)"
  • Network summary shows all clients under "Unknown" network
  • Client details don't show correct VLAN assignment
  • VLAN segmentation is not visible

Expected Behavior

  • Clients should be grouped by their actual network names
  • VLAN numbers should be displayed correctly
  • Network summary should show proper VLAN distribution
  • Client details should include correct network/VLAN info

Root Cause

File: unifi_mcp_optimized.py

Field Name Mismatch

The unifi_exporter.py provides client network information in a "network" field (containing the network name as a string), but the MCP wrapper code expects a "network_id" field (containing a UUID).

Exporter Output Format

{
  "clients": [
    {
      "hostname": "smart-device",
      "ip": "192.168.30.100",
      "mac": "aa:bb:cc:dd:ee:ff",
      "network": "IoT",          ← Provides network NAME (string)
      "network_name": "Unknown",
      "is_wired": false
    }
  ],
  "networks": [
    {
      "_id": "5eaba21f8b76ce04eb94a66d",  ← Network UUID
      "name": "IoT",                      ← Network NAME
      "vlan": 30
    }
  ]
}

Current MCP Code Problem

The code creates a networks lookup dictionary keyed by UUID (_id), then tries to find client networks using network_id (which doesn't exist in client objects):

# Creates lookup by UUID
networks = {n["_id"]: n for n in data.get("networks", [])}

# Tries to get UUID from client (doesn't exist)
network_id = client.get("network_id", "unknown")  # ← Always returns "unknown"

# Can't find "unknown" UUID in networks dict
network_name = networks.get(network_id, {}).get("name", "Unknown")  # ← Always "Unknown"

Proposed Fix

Change the lookup strategy to use network names instead of UUIDs, matching the exporter's output format.

File: unifi_mcp_optimized.py

Three functions need updates:


1. Function: format_network_clients() (Lines 338-376)

Current Code:

def format_network_clients(data: dict) -> str:
    """Format network clients output"""
    clients = data.get("clients", [])
    networks = {n["_id"]: n for n in data.get("networks", [])}

    output = "=== NETWORK CLIENTS ===\n\n"
    output += f"Total: {len(clients)} active clients\n\n"

    # Group by VLAN/network
    by_network = {}
    for client in clients:
        network_id = client.get("network_id", "unknown")
        if network_id not in by_network:
            by_network[network_id] = []
        by_network[network_id].append(client)

    for network_id, network_clients in sorted(
        by_network.items(), key=lambda x: len(x[1]), reverse=True
    ):
        network_name = networks.get(network_id, {}).get("name", "Unknown")
        vlan = networks.get(network_id, {}).get("vlan", "N/A")

Fixed Code:

def format_network_clients(data: dict) -> str:
    """Format network clients output"""
    clients = data.get("clients", [])
    # Create network lookup by name (exporter uses "network" field with name, not ID)
    networks_by_name = {n["name"]: n for n in data.get("networks", [])}

    output = "=== NETWORK CLIENTS ===\n\n"
    output += f"Total: {len(clients)} active clients\n\n"

    # Group by VLAN/network
    by_network = {}
    for client in clients:
        # Exporter provides "network" field with network name, not "network_id"
        network_name = client.get("network", "Unknown")
        if network_name not in by_network:
            by_network[network_name] = []
        by_network[network_name].append(client)

    for network_name, network_clients in sorted(
        by_network.items(), key=lambda x: len(x[1]), reverse=True
    ):
        network_info = networks_by_name.get(network_name, {})
        vlan = network_info.get("vlan", "N/A")

2. Function: format_network_summary() (Lines 413-427)

Current Code:

    # Top networks by client count
    by_network = {}
    for client in clients:
        network_id = client.get("network_id", "unknown")
        by_network[network_id] = by_network.get(network_id, 0) + 1

    output += f"\nTOP NETWORKS:\n"
    networks_dict = {n["_id"]: n for n in networks}
    for network_id, count in sorted(
        by_network.items(), key=lambda x: x[1], reverse=True
    )[:5]:
        name = networks_dict.get(network_id, {}).get("name", "Unknown")
        vlan = networks_dict.get(network_id, {}).get("vlan", "N/A")
        output += f"  • {name} (VLAN {vlan}): {count} clients\n"

Fixed Code:

    # Top networks by client count
    by_network = {}
    for client in clients:
        # Exporter provides "network" field with network name, not "network_id"
        network_name = client.get("network", "Unknown")
        by_network[network_name] = by_network.get(network_name, 0) + 1

    output += f"\nTOP NETWORKS:\n"
    networks_dict = {n["name"]: n for n in networks}
    for network_name, count in sorted(
        by_network.items(), key=lambda x: x[1], reverse=True
    )[:5]:
        network_info = networks_dict.get(network_name, {})
        vlan = network_info.get("vlan", "N/A")
        output += f"  • {network_name} (VLAN {vlan}): {count} clients\n"

3. Function: get_client_details() (Lines 632-650)

Current Code:

    try:
        data = await get_unifi_data()
        clients = data.get("clients", [])
        networks = {n["_id"]: n for n in data.get("networks", [])}

        # Find the client
        for client in clients:
            hostname = client.get("hostname", client.get("name", "Unknown"))
            ip = client.get("ip", "N/A")
            mac = client.get("mac", "N/A")

            # Check if identifier matches hostname, IP, or MAC
            if (client_identifier.lower() in hostname.lower() or
                client_identifier == ip or
                client_identifier.lower() == mac.lower()):

                network_id = client.get("network_id", "unknown")
                network = networks.get(network_id, {})
                network_name = network.get("name", "Unknown")
                vlan = network.get("vlan", "N/A")

Fixed Code:

    try:
        data = await get_unifi_data()
        clients = data.get("clients", [])
        # Create network lookup by name (exporter uses "network" field with name, not ID)
        networks = {n["name"]: n for n in data.get("networks", [])}

        # Find the client
        for client in clients:
            hostname = client.get("hostname", client.get("name", "Unknown"))
            ip = client.get("ip", "N/A")
            mac = client.get("mac", "N/A")

            # Check if identifier matches hostname, IP, or MAC
            if (client_identifier.lower() in hostname.lower() or
                client_identifier == ip or
                client_identifier.lower() == mac.lower()):

                # Exporter provides "network" field with network name, not "network_id"
                network_name = client.get("network", "Unknown")
                network = networks.get(network_name, {})
                vlan = network.get("vlan", "N/A")

Impact

Before Fix

=== NETWORK CLIENTS ===

Total: 94 active clients

Unknown (VLAN N/A) - 94 clients:
  • device1 (192.168.1.100)
    MAC: aa:bb:cc:dd:ee:ff | Wireless
  • device2 (192.168.1.101)
    MAC: aa:bb:cc:dd:ee:ff | Wired
  ...

After Fix

=== NETWORK CLIENTS ===

Total: 94 active clients

IoT (VLAN 30) - 27 clients:
  • device1 (192.168.30.100)
    MAC: aa:bb:cc:dd:ee:ff | Wireless
  ...

User (VLAN 10) - 28 clients:
  • device2 (192.168.10.101)
    MAC: aa:bb:cc:dd:ee:ff | Wired
  ...

MGMT (VLAN ) - 28 clients:
  ...

Guest (VLAN 50) - 11 clients:
  ...

Testing

Test Environment

  • UniFi Controller with multiple VLANs configured
  • Active clients on different VLANs
  • homelab-mcp v3.0.0 Docker container

Test Case 1: List Clients by Network

Command: list_clients

Expected Result:

  • Clients grouped by actual network names (IoT, User, MGMT, Guest, etc.)
  • VLAN numbers displayed correctly for each network
  • No "Unknown (VLAN N/A)" entries (unless client truly has no network assigned)

Test Case 2: Network Summary

Command: get_network_stats

Expected Result:

TOP NETWORKS:
  • IoT (VLAN 30): 27 clients
  • User (VLAN 10): 28 clients
  • MGMT (VLAN ): 28 clients
  • Guest (VLAN 50): 11 clients

Test Case 3: Individual Client Details

Command: get_client_details("device-name")

Expected Result:

=== CLIENT DETAILS: device-name ===

IP: 192.168.30.100
MAC: aa:bb:cc:dd:ee:ff
Connection: Wireless
Network: IoT (VLAN 30)   ← Should show correct network and VLAN
SSID: HomeWiFi
...

Verification Steps

  1. Deploy fixed version to test environment
  2. Clear UniFi cache: rm -f /tmp/unifi_mcp_cache/unifi_data.json
  3. Run list_clients and verify networks show correctly
  4. Run get_network_stats and verify TOP NETWORKS section
  5. Run get_client_details for devices on different VLANs
  6. Confirm all network/VLAN information displays properly

Files Changed

  • unifi_mcp_optimized.py
    • Lines 338-376: Update format_network_clients() to use network name lookup
    • Lines 413-427: Update format_network_summary() to use network name lookup
    • Lines 632-650: Update get_client_details() to use network name lookup

Backward Compatibility

✅ Fully backward compatible

  • No changes to MCP tool signatures or API
  • Works with existing UniFi controller versions
  • Network name matching is standard across UniFi API versions
  • Handles missing/unknown networks gracefully (defaults to "Unknown")

Why This Bug Exists

The field name mismatch suggests one of the following:

  1. The unifi_exporter.py output format changed but unifi_mcp_optimized.py wasn't updated
  2. Original implementation was based on UniFi API documentation rather than actual output
  3. The v3.0.0 FastMCP migration (Jan 14, 2026) introduced this regression

The exporter clearly provides network names in the "network" field, but the wrapper was coded to expect UUIDs in a "network_id" field that doesn't exist.

Related Issues

None found. This appears to be the first report of this issue, possibly because:

  • Most users have simple single-VLAN networks
  • The bug went unnoticed in basic testing
  • Users assumed "Unknown" was expected behavior

Checklist

  • Code follows project style guidelines
  • All three affected functions updated consistently
  • Changes are backward compatible
  • Tested with multi-VLAN configuration
  • Verified clients display correct network names
  • Verified VLAN numbers display correctly
  • Verified client details show proper network info

All network clients show as 'Unknown (VLAN N/A)' due to field name
mismatch between exporter output and MCP wrapper code. The exporter
provides network info in a 'network' field (network name), but the
wrapper expects 'network_id' (UUID).

Changes:
- format_network_clients(): Use network name lookup instead of UUID
- format_network_summary(): Use network name lookup instead of UUID
- get_client_details(): Use network name lookup instead of UUID

Fixes VLAN segmentation visibility where all clients appeared under
'Unknown' network regardless of actual VLAN assignment.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes incorrect “Unknown (VLAN N/A)” client network/VLAN display in the UniFi MCP server by aligning client-to-network lookups with the exporter’s client network fields.

Changes:

  • Update format_network_clients() to group clients by exported network name and resolve VLAN via a name-keyed network lookup.
  • Update format_network_summary() to compute “Top networks” using network names and show VLAN from matching network config.
  • Update get_client_details() to display and resolve a client’s network/VLAN using the exported network name.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread unifi_mcp_optimized.py Outdated
clients = data.get("clients", [])
networks = {n["_id"]: n for n in data.get("networks", [])}
# Create network lookup by name (exporter uses "network" field with name, not ID)
networks = {n["name"]: n for n in data.get("networks", [])}

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

networks = {n["name"]: n ...} will throw KeyError if any network object is missing name. Build this lookup defensively (use n.get("name") and skip missing names) so get_client_details can’t fail due to one malformed network entry.

Suggested change
networks = {n["name"]: n for n in data.get("networks", [])}
networks = {}
for n in data.get("networks", []):
name = n.get("name")
if not name:
continue
networks[name] = n

Copilot uses AI. Check for mistakes.
Comment thread unifi_mcp_optimized.py Outdated
clients = data.get("clients", [])
networks = {n["_id"]: n for n in data.get("networks", [])}
# Create network lookup by name (exporter uses "network" field with name, not ID)
networks_by_name = {n["name"]: n for n in data.get("networks", [])}

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

networks_by_name = {n["name"]: n ...} will raise a KeyError if any network entry lacks a name field (elsewhere in the repo network names are accessed via net.get("name", "Unknown"), suggesting this can be missing). Build this mapping defensively (e.g., use n.get("name") and skip falsy names) so listing clients can’t crash on unexpected controller responses.

Suggested change
networks_by_name = {n["name"]: n for n in data.get("networks", [])}
networks_by_name = {
n_name: n
for n in data.get("networks", [])
if (n_name := n.get("name"))
}

Copilot uses AI. Check for mistakes.
Comment thread unifi_mcp_optimized.py Outdated
Comment on lines +346 to +347
# Exporter provides "network" field with network name, not "network_id"
network_name = client.get("network", "Unknown")

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This groups clients using client.get("network") as a network name, but unifi_exporter.py exports both network and network_name and even uses network_name for grouping in its own output. To avoid reintroducing the display bug on controllers where network is an ID/code, prefer network_name for display/grouping and fall back to network only when network_name is missing.

Suggested change
# Exporter provides "network" field with network name, not "network_id"
network_name = client.get("network", "Unknown")
# Prefer human-readable network_name; fall back to network (ID/code) and then "Unknown"
network_name = client.get("network_name") or client.get("network") or "Unknown"

Copilot uses AI. Check for mistakes.
Comment thread unifi_mcp_optimized.py Outdated
output += f"\nTOP NETWORKS:\n"
networks_dict = {n["_id"]: n for n in networks}
for network_id, count in sorted(
networks_dict = {n["name"]: n for n in networks}

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

networks_dict = {n["name"]: n for n in networks} can raise KeyError if any network object lacks name. Consider using n.get("name") and skipping entries without a name so get_network_stats can still render even with partial/odd controller data.

Suggested change
networks_dict = {n["name"]: n for n in networks}
networks_dict = {n.get("name"): n for n in networks if n.get("name")}

Copilot uses AI. Check for mistakes.
Comment thread unifi_mcp_optimized.py Outdated
Comment on lines +648 to +649
# Exporter provides "network" field with network name, not "network_id"
network_name = client.get("network", "Unknown")

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

network_name = client.get("network") assumes the network field is the human-readable name. Since the exporter also provides network_name, and uses it elsewhere for grouping, prefer client.get("network_name") for the displayed name (with a fallback to network) so client details don’t show raw IDs/codes on some controller versions.

Suggested change
# Exporter provides "network" field with network name, not "network_id"
network_name = client.get("network", "Unknown")
# Prefer human-readable network_name, fall back to network if needed
network_name = client.get("network_name") or client.get("network", "Unknown")

Copilot uses AI. Check for mistakes.
Address Copilot review feedback:
- Use n.get("name") with walrus operator to avoid KeyError on networks
  without a name field in format_network_clients() and get_client_details()
- Use n.get("name") filter in format_network_summary() networks_dict
- Prefer network_name field over network for display (more reliable
  human-readable name); fall back to network then Unknown
@bjeans

bjeans commented Apr 7, 2026

Copy link
Copy Markdown
Owner

@claude can you review and let me know if this is safe to merge, or if there are additional updates needed.

@claude

claude Bot commented Apr 7, 2026

Copy link
Copy Markdown

Claude Code is working…

I'll analyze this and get back to you.

View job run

@bjeans bjeans self-assigned this Apr 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants