Build CLIs First, Wrap as MCPs Second

Build CLIs First, Wrap as MCPs Second

MCP (Model Context Protocol) servers give AI agents access to tools. Tempting to build MCP servers directly. Better approach: build good CLIs first, then wrap them as MCPs.

Good CLIs are multi-interface. Usable from shell. Scriptable. Composable with pipes. Testable standalone. Accessible to humans without AI. Then wrap as MCP for AI agent access.

MCP-first locks you to the MCP protocol. CLI-first gives you flexibility.

The Multi-Interface Advantage#

A good CLI like mail-app-cli works in multiple contexts:

Shell usage:

mail-app-cli messages list -a "Gmail" -m "INBOX" --unread

Script automation:

#!/bin/bash
unread=$(mail-app-cli messages list -a "Gmail" -m "INBOX" --unread | jq 'length')
if [ $unread -gt 0 ]; then
  notify-send "You have $unread unread emails"
fi

Emacs integration:

(defun check-email ()
  (interactive)
  (shell-command "mail-app-cli messages list -a Gmail -m INBOX --unread"))

MCP wrapper:

@mcp_server.tool()
def list_unread_emails(account: str, mailbox: str):
    result = subprocess.run(
        ['mail-app-cli', 'messages', 'list',
         '-a', account, '-m', mailbox, '--unread'],
        capture_output=True
    )
    return json.loads(result.stdout)

AI agent access:

# Agent uses MCP
emails = agent.use_tool("list_unread_emails",
                        account="Gmail",
                        mailbox="INBOX")

Same CLI. Five interfaces. Build once, use everywhere.

Testing Without AI#

CLI-first means testing doesn’t require AI:

# Test the CLI directly
mail-app-cli send -a "Gmail" -t "test@example.com" -s "Test" --body "Testing"
echo $?  # Check exit code

# Test with various inputs
mail-app-cli messages list -a "Gmail" -m "INBOX" --limit 1
mail-app-cli messages list -a "Gmail" -m "INBOX" --since "2025-12-01"

Works. Fails. Both testable immediately.

MCP-first requires AI to test. CLI-first works standalone.

Composability with Unix Tools#

CLIs integrate with existing ecosystem:

# Find urgent emails, extract senders, count occurrences
mail-app-cli messages list -a "Gmail" -m "INBOX" --unread | \
  jq -r '.[] | select(.subject | contains("URGENT")) | .sender' | \
  sort | uniq -c | sort -rn

# Monitor inbox, alert on VIP emails
while true; do
  mail-app-cli messages list -a "Gmail" -m "INBOX" --unread | \
    jq -r '.[] | select(.sender | contains("ceo@")) | .subject' | \
    while read subject; do
      osascript -e "display notification \"$subject\" with title \"VIP Email\""
    done
  sleep 300
done

# Archive old read messages
mail-app-cli messages list -a "Gmail" -m "INBOX" | \
  jq -r '.[] | select(.read==true and .dateSent < "2025-11-01") | .id' | \
  xargs -I {} mail-app-cli messages archive {} -a "Gmail" -m "INBOX"

MCP tools don’t compose with grep, jq, xargs. CLIs do.

Error Handling and Exit Codes#

Good CLIs follow Unix conventions:

mail-app-cli messages show 12345 -a "Gmail" -m "INBOX"
if [ $? -eq 0 ]; then
  echo "Success"
else
  echo "Failed" >&2
fi

Exit code 0 = success. Non-zero = failure. Scripts can check and react.

MCP errors are JSON responses. Harder to integrate with shell logic.

JSON Output for Structured Data#

Good CLIs output JSON for parsing:

$ mail-app-cli accounts list
[
  {
    "name": "Gmail",
    "emailAddress": "user@gmail.com",
    "enabled": true
  }
]

Pipe to jq for filtering, transformation, extraction. Standard tools work.

MCP responses are protocol-specific. Require MCP client to parse.

Examples: Good CLIs That Wrap as MCPs#

gh (GitHub CLI):

Standalone: gh pr list --state open

As MCP: GitHub MCP server wraps gh commands

mail-app-cli:

Standalone: mail-app-cli messages list

As MCP: mail-app MCP server wraps mail-app-cli

twist-cli:

Standalone: twist-cli messages list --channel general

As MCP: twist MCP server wraps twist-cli

Pattern: CLI provides functionality. MCP provides AI agent interface.

Building a CLI-First Tool#

Design principles:

  1. Subcommands for organization: tool <noun> <verb> (e.g., mail-app-cli messages list)
  2. JSON output: Machine-readable structured data
  3. Unix exit codes: 0 for success, non-zero for errors
  4. Stdin/stdout: Accept input via stdin, output via stdout
  5. Clear help: --help on every command
  6. No required config files: Use flags or env vars

Example structure:

mycli
├── accounts list
├── accounts show <name>
├── messages list [--unread] [--since DATE]
├── messages show <id>
├── messages send --to EMAIL --subject TEXT
└── search <query> [--limit N]

Implementation in Go:

// Using cobra for CLI framework
func init() {
    rootCmd.AddCommand(messagesCmd)
    messagesCmd.AddCommand(messagesListCmd)
    messagesCmd.AddCommand(messagesShowCmd)
}

var messagesListCmd = &cobra.Command{
    Use:   "list",
    Short: "List messages",
    Run: func(cmd *cobra.Command, args []string) {
        messages := fetchMessages()
        output, _ := json.MarshalIndent(messages, "", "  ")
        fmt.Println(string(output))
    },
}

Wrapping as MCP#

Simple wrapper around CLI:

from mcp.server import Server
import subprocess
import json

mcp = Server("mycli-mcp")

@mcp.tool()
def list_messages(account: str, mailbox: str, unread: bool = False):
    """List messages from mailbox"""

    cmd = ['mycli', 'messages', 'list', '-a', account, '-m', mailbox]
    if unread:
        cmd.append('--unread')

    result = subprocess.run(cmd, capture_output=True, text=True)

    if result.returncode != 0:
        raise Exception(f"CLI error: {result.stderr}")

    return json.loads(result.stdout)

@mcp.tool()
def send_message(account: str, to: str, subject: str, body: str):
    """Send email"""

    result = subprocess.run([
        'mycli', 'send',
        '-a', account,
        '-t', to,
        '-s', subject,
        '--body', body
    ], capture_output=True, text=True)

    if result.returncode != 0:
        raise Exception(f"CLI error: {result.stderr}")

    return {"status": "sent"}

MCP server is thin wrapper. CLI does the work.

Benefits of CLI-First Approach#

Development velocity:

Test CLI directly without AI. Faster iteration. Immediate feedback.

Debugging:

CLI fails? Run it manually. See exact error. Fix immediately.

MCP fails? Debug through AI agent. Slower feedback loop.

Human usability:

CLI remains useful without AI. Developers use it directly. Scripts use it. Emacs integrates it.

MCP-only tools require AI agent. Humans can’t access them directly.

Backwards compatibility:

MCP protocol changes? Wrapper updates. CLI unchanged. Existing scripts continue working.

Multi-agent support:

Different AI frameworks use different MCP implementations. CLI works with all of them via simple wrapper.

When MCP-First Makes Sense#

AI-specific operations:

If the tool only makes sense in AI context, build MCP directly. Example: “summarize this conversation” - no CLI equivalent.

Real-time streaming:

MCPs handle streaming better than CLIs. For operations that stream results (like LLM generation), MCP-first might be appropriate.

Stateful operations:

MCPs maintain state across calls naturally. CLIs are stateless by design. For operations requiring state, MCP might be simpler.

The Pattern in Practice#

mail-app-cli example:

CLI: 50K lines of Go. Complete interface to Mail.app.

MCP wrapper: ~200 lines of Python. Calls CLI, returns JSON.

Benefit:

  • CLI usable standalone (shell, Emacs, scripts)
  • MCP provides AI agent access
  • Both work from same codebase
  • Testing doesn’t require AI

gh example:

GitHub’s gh CLI exists. Community builds MCP wrappers. Multiple MCP implementations possible. All use same underlying CLI.

twist-cli example:

CLI for Twist API. Works standalone. Can be wrapped as MCP for AI access.

Pattern: substantial CLI, thin MCP wrapper.

Implementation Recommendation#

Step 1: Build good CLI

  • Comprehensive subcommands
  • JSON output
  • Clear help
  • Unix conventions

Step 2: Test standalone

  • Shell scripts
  • Manual testing
  • Integration with other tools

Step 3: Wrap as MCP

  • Thin wrapper calling CLI
  • MCP tool definitions
  • Error handling

Step 4: Use both

  • Humans use CLI directly
  • AI agents use MCP
  • Same functionality, different interfaces

CLI-first maximizes utility. MCP wrapper adds AI access. Both remain useful.