← Back to Home

Building an MCP Server for Non-LLM Clients (CLIs, IDEs, Pipelines)

mcp-implementationdeveloper-toolsinfrastructure
#model-context-protocol#cli-tools#ide-extensions#ci-cd-pipelines#developer-experience#internal-tooling#mcp-servers#automation#context-access

The Problem: Context Access Is Locked Behind LLM Abstractions

You've built an MCP server that provides rich context from your organization's systems—code repositories, documentation, test results, deployment history. It works great with Claude and GPT-4. Then your infrastructure team asks: "Can our CI pipeline use this to check if proposed changes conflict with recent deployments?" Your IDE team wants to surface relevant documentation as developers type. Your CLI tools need access to the same context data for automation scripts.

The answer should be yes, but most MCP implementations assume an LLM client. The protocol documentation focuses on agent use cases. Example code shows prompt construction and model interaction. Your MCP server returns context formatted for language models, not structured data for programmatic consumption. When you try to integrate with non-LLM clients, you discover the impedance mismatch.

Your CI pipeline doesn't need natural language responses. It needs structured data: "Here are the 3 deployments from the last 48 hours that touched authentication, with commit SHAs and timestamps." Your IDE extension doesn't want a paragraph explaining the API—it wants JSON with method signatures, parameter types, and usage examples formatted for autocomplete. Your CLI tool doesn't need conversational context—it needs queryable facts it can pipe to other Unix tools.

Most teams respond by building separate APIs alongside their MCP servers. The MCP server for LLMs, REST APIs for everything else. Now you're maintaining two implementations of the same data access logic, two authentication systems, two sets of caching, two monitoring dashboards. When the MCP server gets updated, the REST API lags behind. When the REST API evolves, the MCP server doesn't benefit.

The fundamental issue: MCP is a protocol for context access, not specifically for LLM-agent interaction. But most implementations conflate the two. The protocol itself is client-agnostic—it's JSON-RPC over stdio or HTTP. But the mental model, tooling, and examples all assume LLM consumption. This creates an artificial boundary that prevents MCP servers from becoming true organizational context infrastructure.

The Mental Model: MCP as Context API, Not Agent Protocol

Stop thinking of MCP as "the protocol for AI agents." Start thinking of it as a standardized context access layer that any client can use—LLMs, CLIs, IDEs, pipelines, whatever needs organizational context.

The key insight: MCP servers provide structured context, not conversational responses. The protocol returns resources as data. What clients do with that data—feed it to an LLM, display it in an IDE, process it in a pipeline—is their concern, not the server's.

The client-agnostic abstraction:

MCP servers expose three primitives: resources, tools, and prompts. Resources are data you can read. Tools are operations you can execute. Prompts are templates for common requests. None of these are inherently LLM-specific.

A resource might be "recent deployment history." An LLM client requests this to answer "what changed recently?" A CI pipeline requests the same resource to check for conflicts. Same server, same protocol, same data—different consumers.

A tool might be "validate configuration against schema." An LLM calls this while helping a user debug. A pre-commit hook calls it to prevent invalid configs from being committed. Same tool, same execution, different integration points.

The format flexibility insight:

MCP resources have MIME types. Most examples show text/plain or text/markdown for LLM consumption. But nothing prevents application/json, application/xml, or custom types for structured data. The protocol doesn't care about format—it just transports it.

This means your MCP server can return the same logical resource in multiple formats. LLM clients get markdown with natural language explanations. API clients get JSON with structured fields. IDE clients get language-specific formats with type information.

The tooling gap:

The biggest barrier isn't technical—it's tooling. The MCP SDKs focus on LLM integration. The example servers all assume conversational usage. The documentation emphasizes agent patterns. There's very little guidance on building MCP clients for non-LLM use cases.

This is fixable. The protocol is already client-agnostic. We just need to build the right abstractions and patterns for non-LLM consumption.

The value proposition for non-LLM clients:

Why use MCP instead of traditional APIs? Three reasons:

Standardization: MCP provides consistent protocol across all context sources. Your CLI tool doesn't need custom client libraries for each data source—one MCP client works everywhere.

Capability discovery: MCP servers advertise available resources and tools. Clients can query "what can you provide?" This enables dynamic tooling that adapts to available context.

Access control integration: MCP's protocol-level auth and permissions work the same for all clients. You don't need separate auth for LLM access vs. CLI access vs. pipeline access.

Architecture: MCP Server Supporting Multiple Client Types

The architecture separates protocol handling from response formatting.

Figure: Architecture MCP Server Supporting Multiple Client Types
Figure: Architecture MCP Server Supporting Multiple Client Types

Component Responsibilities

Protocol Handler implements MCP JSON-RPC spec. It's client-agnostic—just protocol marshaling and unmarshaling.

Auth Layer validates client credentials and establishes identity. Same auth for all clients, whether LLM or CLI.

Request Router maps MCP requests to internal handlers. Delegates to resource providers or tool executors based on request type.

Resource Provider fetches data from sources. Returns structured data, not formatted text. Format comes later.

Tool Executor runs operations. Same execution logic regardless of client type.

Format Adapter selects appropriate formatter based on client preferences. LLM clients get markdown, API clients get JSON, IDE clients get language-specific formats.

Protocol Flow

  1. Client connects via stdio or HTTP
  2. Auth layer validates credentials
  3. Client requests resource list (capability discovery)
  4. Server returns available resources with metadata
  5. Client requests specific resource with preferred format
  6. Resource provider fetches data
  7. Format adapter converts to requested format
  8. Server returns formatted response
  9. Client consumes data (display, processing, or LLM feeding)

Key Architectural Decisions

Data-first design: Internal representation is structured data (Python dicts, dataclasses). Formatting is the final step, not embedded in data access.

Format negotiation: Clients specify preferred MIME type. Server provides best match or default. This enables progressive enhancement—new formats added without breaking existing clients.

Stateless protocol: Each request is independent. No session state in protocol layer. This works for both LLM conversations and stateless automation.

Implementation: Building Client-Agnostic MCP Servers

Layer 1: Protocol-Agnostic Resource Providers

Resource providers return structured data, not formatted strings.

code
from typing import Dict, Any, List, Optionalfrom dataclasses import dataclass, asdictfrom datetime import datetimefrom enum import Enumclass ResourceFormat(Enum):    JSON = "application/json"    MARKDOWN = "text/markdown"    YAML = "application/yaml"    XML = "application/xml"@dataclassclass Deployment:    """    Structured deployment data.    Not formatted for any specific client.    """    deployment_id: str    service: str    version: str    environment: str    deployed_by: str    deployed_at: datetime    commit_sha: str    status: str    affected_components: List[str]        def to_dict(self) -> Dict[str, Any]:        return {            **asdict(self),            "deployed_at": self.deployed_at.isoformat()        }class DeploymentResourceProvider:    """    Provides deployment context.    Client-agnostic—returns data, not formatted text.    """        def __init__(self, deployment_store):        self.store = deployment_store        async def get_recent_deployments(        self,        hours: int = 48,        environment: Optional[str] = None    ) -> List[Deployment]:        """        Fetch recent deployments as structured data.        Clients handle formatting.        """        since = datetime.utcnow() - timedelta(hours=hours)                deployments = await self.store.query(            deployed_after=since,            environment=environment        )                return [            Deployment(                deployment_id=d["id"],                service=d["service"],                version=d["version"],                environment=d["environment"],                deployed_by=d["deployed_by"],                deployed_at=datetime.fromisoformat(d["deployed_at"]),                commit_sha=d["commit_sha"],                status=d["status"],                affected_components=d.get("affected_components", [])            )            for d in deployments        ]        async def get_deployment_conflicts(        self,        proposed_components: List[str],        hours: int = 48    ) -> Dict[str, Any]:        """        Check if proposed changes conflict with recent deployments.        Returns structured conflict data for programmatic use.        """        recent = await self.get_recent_deployments(hours=hours)                conflicts = []        for deployment in recent:            overlapping = set(proposed_components) & set(deployment.affected_components)                        if overlapping:                conflicts.append({                    "deployment_id": deployment.deployment_id,                    "service": deployment.service,                    "version": deployment.version,                    "deployed_at": deployment.deployed_at.isoformat(),                    "conflicting_components": list(overlapping)                })                return {            "has_conflicts": len(conflicts) > 0,            "conflict_count": len(conflicts),            "conflicts": conflicts,            "checked_components": proposed_components        }

Production considerations:

  • Data classes return structured data, not formatted strings. This enables multiple formatting options without changing data access logic.
  • Methods return domain objects (Deployment) not dictionaries. This provides type safety and makes refactoring safer.
  • Business logic (conflict detection) operates on structured data. Output is still structured, not prose.

Layer 2: Multi-Format Response Formatter

Format adapter converts structured data to client-preferred format.

code
import jsonimport yamlfrom typing import Any, Dict, Listclass ResponseFormatter:    """    Converts structured data to client-requested format.    Enables same data, multiple presentations.    """        def format(        self,        data: Any,        format_type: ResourceFormat    ) -> str:        """        Format data according to client preference.        """        if format_type == ResourceFormat.JSON:            return self._to_json(data)        elif format_type == ResourceFormat.MARKDOWN:            return self._to_markdown(data)        elif format_type == ResourceFormat.YAML:            return self._to_yaml(data)        else:            # Default to JSON for unknown formats            return self._to_json(data)        def _to_json(self, data: Any) -> str:        """        JSON format for API clients, CLIs, pipelines.        """        if isinstance(data, list):            # List of objects            return json.dumps(                [self._serialize(item) for item in data],                indent=2            )        else:            return json.dumps(self._serialize(data), indent=2)        def _to_markdown(self, data: Any) -> str:        """        Markdown format for LLM clients, documentation.        """        if isinstance(data, list) and len(data) > 0 and isinstance(data[0], Deployment):            # List of deployments            md = "# Recent Deployments\n\n"                        for deployment in data:                md += f"## {deployment.service} v{deployment.version}\n\n"                md += f"- **Environment:** {deployment.environment}\n"                md += f"- **Deployed by:** {deployment.deployed_by}\n"                md += f"- **Deployed at:** {deployment.deployed_at.isoformat()}\n"                md += f"- **Commit:** `{deployment.commit_sha}`\n"                md += f"- **Status:** {deployment.status}\n"                                if deployment.affected_components:                    md += f"- **Components:** {', '.join(deployment.affected_components)}\n"                                md += "\n"                        return md                elif isinstance(data, dict) and "conflicts" in data:            # Conflict detection result            md = "# Deployment Conflict Check\n\n"                        if data["has_conflicts"]:                md += f"⚠️ **{data['conflict_count']} conflicts detected**\n\n"                                for conflict in data["conflicts"]:                    md += f"## Conflict with {conflict['service']} deployment\n\n"                    md += f"- Deployed: {conflict['deployed_at']}\n"                    md += f"- Version: {conflict['version']}\n"                    md += f"- Overlapping components: {', '.join(conflict['conflicting_components'])}\n\n"            else:                md += "✓ **No conflicts detected**\n\n"                md += f"Checked components: {', '.join(data['checked_components'])}\n"                        return md                # Fallback for unknown data types        return f"```json\n{json.dumps(self._serialize(data), indent=2)}\n```"        def _to_yaml(self, data: Any) -> str:        """        YAML format for configuration-focused clients.        """        return yaml.dump(self._serialize(data), default_flow_style=False)        def _serialize(self, obj: Any) -> Any:        """Convert objects to serializable dicts"""        if hasattr(obj, 'to_dict'):            return obj.to_dict()        elif isinstance(obj, datetime):            return obj.isoformat()        elif isinstance(obj, (list, tuple)):            return [self._serialize(item) for item in obj]        elif isinstance(obj, dict):            return {k: self._serialize(v) for k, v in obj.items()}        else:            return obj

Production considerations:

  • Same data, multiple formats. LLM clients get readable markdown. API clients get structured JSON. Configuration tools get YAML.
  • Format selection is declarative. Clients specify preference in request headers or MCP protocol metadata.
  • Markdown formatting is semantic, not just pretty. Includes headers, lists, code blocks that LLMs can parse effectively.

Layer 3: MCP Server with Format Negotiation

MCP server handles protocol and delegates to formatters.

code
from typing import Dict, Any, Optionalimport jsonclass MCPServer:    """    MCP server supporting multiple client types.    Protocol-compliant, client-agnostic.    """        def __init__(        self,        resource_providers: Dict[str, Any],        formatter: ResponseFormatter    ):        self.providers = resource_providers        self.formatter = formatter        async def handle_list_resources(self) -> Dict[str, Any]:        """        MCP protocol: list available resources.        Returns metadata about what's accessible.        """        return {            "resources": [                {                    "uri": "deployment://recent",                    "name": "Recent Deployments",                    "description": "Deployments from the last 48 hours",                    "mimeTypes": [                        ResourceFormat.JSON.value,                        ResourceFormat.MARKDOWN.value,                        ResourceFormat.YAML.value                    ]                },                {                    "uri": "deployment://conflicts",                    "name": "Deployment Conflicts",                    "description": "Check for conflicts with recent deployments",                    "mimeTypes": [                        ResourceFormat.JSON.value,                        ResourceFormat.MARKDOWN.value                    ]                }            ]        }        async def handle_read_resource(        self,        uri: str,        format_preference: Optional[str] = None    ) -> Dict[str, Any]:        """        MCP protocol: read resource with format negotiation.        """        # Default to JSON if no preference        format_type = ResourceFormat.JSON        if format_preference:            try:                format_type = ResourceFormat(format_preference)            except ValueError:                pass  # Use default                # Route to appropriate provider        if uri.startswith("deployment://recent"):            provider = self.providers["deployment"]                        # Parse query parameters from URI            params = self._parse_uri_params(uri)                        deployments = await provider.get_recent_deployments(                hours=int(params.get("hours", 48)),                environment=params.get("environment")            )                        # Format response            content = self.formatter.format(deployments, format_type)                        return {                "contents": [{                    "uri": uri,                    "mimeType": format_type.value,                    "text": content                }]            }                elif uri.startswith("deployment://conflicts"):            provider = self.providers["deployment"]            params = self._parse_uri_params(uri)                        # Get components from parameters            components = params.get("components", "").split(",")                        conflicts = await provider.get_deployment_conflicts(                proposed_components=components,                hours=int(params.get("hours", 48))            )                        content = self.formatter.format(conflicts, format_type)                        return {                "contents": [{                    "uri": uri,                    "mimeType": format_type.value,                    "text": content                }]            }                else:            raise ValueError(f"Unknown resource URI: {uri}")        def _parse_uri_params(self, uri: str) -> Dict[str, str]:        """Extract query parameters from URI"""        if "?" not in uri:            return {}                _, query = uri.split("?", 1)        params = {}                for param in query.split("&"):            if "=" in param:                key, value = param.split("=", 1)                params[key] = value                return params

Production considerations:

  • Protocol compliance is strict. Responses match MCP spec exactly, regardless of client type.
  • Format negotiation is explicit. Clients specify preference, server provides best match.
  • URI parameters enable rich queries. Not just static resources—parameterized data access.

Layer 4: Non-LLM Client Examples

How different clients consume the same MCP server.

code
# CLI Client for deployment checksimport asyncioimport jsonimport sysclass MCPCLIClient:    """    CLI client for MCP server.    Consumes structured JSON, not conversational responses.    """        def __init__(self, server_url: str):        self.server_url = server_url        async def check_conflicts(self, components: List[str]) -> int:        """        Check deployment conflicts, return exit code.        0 = no conflicts, 1 = conflicts found        """        # Request JSON format        uri = f"deployment://conflicts?components={','.join(components)}"                response = await self._mcp_request(            "resources/read",            {                "uri": uri,                "format": "application/json"            }        )                # Parse JSON response        content = response["contents"][0]["text"]        data = json.loads(content)                # Output for pipeline consumption        if data["has_conflicts"]:            print(f"ERROR: {data['conflict_count']} deployment conflicts detected", file=sys.stderr)                        for conflict in data["conflicts"]:                print(f"  - {conflict['service']}: {', '.join(conflict['conflicting_components'])}",                       file=sys.stderr)                        return 1        else:            print("No deployment conflicts detected")            return 0        async def list_recent_deployments(self, format: str = "json"):        """        List recent deployments in requested format.        """        uri = "deployment://recent?hours=48"                response = await self._mcp_request(            "resources/read",            {                "uri": uri,                "format": f"application/{format}"            }        )                # Output to stdout for piping        print(response["contents"][0]["text"])        async def _mcp_request(self, method: str, params: Dict) -> Dict:        """        Make MCP JSON-RPC request.        """        # In production: actual HTTP or stdio transport        # For demo: direct server call        pass# Usage in CI pipelineasync def main():    client = MCPCLIClient("http://mcp-server:3000")        # Check if deployment conflicts with recent changes    components = sys.argv[1:] if len(sys.argv) > 1 else []        exit_code = await client.check_conflicts(components)    sys.exit(exit_code)# In CI pipeline:# ./check-conflicts.py auth-service user-service# Exit 0 if safe, 1 if conflicts

Production considerations:

  • CLI clients prefer structured output (JSON, CSV). Easy to parse, pipe to other tools.
  • Exit codes matter. Zero for success, non-zero for failure. Standard Unix conventions.
  • Stderr for diagnostics, stdout for data. Enables piping while preserving error visibility.

Pitfalls & Failure Modes

Assuming LLM-Only Consumption

Teams build MCP servers that only return markdown formatted for LLMs.

Symptom: Non-LLM clients can't parse responses. CLI tools have to regex parse markdown. Pipelines fail on format changes.

Why it happens: All examples show LLM usage. Teams optimize for that use case only.

Detection: Try using MCP server from a bash script. If you're parsing markdown with grep and sed, format support is missing.

Prevention: Return structured data internally, format as final step. Support multiple MIME types per resource.

No Format Negotiation

Teams hardcode response format instead of letting clients specify preference.

Symptom: LLM clients get JSON when they want markdown. CLI clients get markdown when they want JSON. Everyone's post-processing.

Why it happens: Format negotiation seems like extra complexity. Easier to pick one format.

Detection: Check if different clients request same resource in different formats. If not possible, negotiation is missing.

Prevention: Implement format preference in MCP protocol. Use MIME types from spec. Let clients specify, server provides.

Stateful Protocol Assumptions

Teams build MCP servers that assume conversational state across requests.

Symptom: Works for LLM conversations but fails for stateless CLI/pipeline usage. Server expects previous requests that never happened.

Why it happens: LLM usage is often conversational. Teams carry that assumption into protocol.

Detection: Single-shot requests (no previous context) fail or behave unexpectedly.

Prevention: MCP protocol is stateless. Each request must be self-contained. Context comes from URI parameters, not session state.

Over-Engineered Capability Discovery

Teams build complex capability discovery that requires multiple roundtrips before getting data.

Symptom: Simple CLI operations require calling 5 MCP methods just to fetch one resource. Latency dominates.

Why it happens: Following agent patterns where discovery is interactive. Works for LLMs, terrible for automation.

Detection: Measure requests-per-operation for CLI clients. If >3 requests for simple operations, discovery is too heavy.

Prevention: Support both full discovery (for dynamic clients) and direct access (for clients that know what they want). Don't force discovery on every request.

Auth Complexity Mismatch

Teams implement auth suited for user-driven LLM sessions but incompatible with service accounts in pipelines.

Symptom: CI pipelines can't authenticate. Service-to-service calls fail. OAuth flows don't work for automation.

Why it happens: Auth designed for human users accessing via LLM interfaces. Pipelines need machine-to-machine auth.

Detection: Try running MCP client in GitHub Actions or Jenkins. If auth fails, service account support is missing.

Prevention: Support both user auth (OAuth, JWT) and service auth (API keys, mTLS). Pipelines use service accounts, LLM users use personal credentials.

Summary & Next Steps

MCP is a protocol for context access, not exclusively for LLM agents. The same MCP server can serve LLM clients, CLI tools, IDE extensions, and CI pipelines by separating data access from response formatting. Return structured data internally, format based on client preference. Support multiple MIME types. Keep protocol stateless.

The key insights: resource providers return structured data, not formatted strings. Format adapters convert to client-preferred representation. Protocol is client-agnostic—JSON-RPC works for any consumer. Capability discovery enables dynamic tooling. Auth must support both user and service account patterns.

Build multi-client MCP servers:

This week: Audit existing MCP server. Check if it returns structured data or formatted strings. Identify where LLM assumptions leaked into data layer. Separate concerns.

Next sprint: Implement format negotiation. Add JSON formatter for CLI/pipeline clients alongside markdown for LLMs. Test with simple CLI client requesting JSON.

Within month: Build non-LLM integrations. Create CLI tool that queries MCP server for deployment data. Integrate with CI pipeline for conflict checking. Add IDE extension that uses MCP for code context.

Test the abstraction. Same MCP server should serve LLM conversation and CLI automation without modification. If you need different servers for different clients, data/format separation is incomplete.

The goal is MCP as organizational context infrastructure that any tool can access, not just AI agents. Build for the protocol, not for specific clients.