8min.

MCP: The Open Protocol That Turns LLM Chatbots into Intelligent Agents

LLMs have started to become widely known. They are used to generate text, answer questions, translate texts, and more. These models are becoming increasingly powerful and are employed across diverse fields.

LLMs powers all the fancy IA you use like GPT, BERT, Claude, LLaMA, Deepseek, or Mistral.

We are reaching a point where LLMs have become smart enough. The next goal is enabling them to perform actions. For example, we want to be able to book a train or order a pizza directly from a chatbot.

The next step is to transform the LLM into a real assistant, an agent.

This is where MCP comes in. It is a protocol that allows a client to interact with a server capable of performing actions. The client sends a request to the server, which executes the action and returns the result.

Section intitulée genesis-of-the-protocolGenesis of the Protocol

Some LLMs already support tools, but these are usually proprietary solutions. MCP aims to standardize how LLMs interact with tools.

On November 25, 2024, Anthropic announced the release of MCP. The protocol is open-source and free to use, designed to be simple and easy to implement.

On the official website, they describe MCP as:

MCP is an open protocol that standardizes how applications provide context to LLMs. Think of MCP like a USB-C port for AI applications. Just as USB-C provides a standardized way to connect your devices to various peripherals and accessories, MCP provides a standardized way to connect AI models to different data sources and tools.

Section intitulée how-it-worksHow It Works

Section intitulée the-big-pictureThe Big Picture

At its core, MCP follows a client-server architecture where a host application connects to multiple servers:

MCP big picture

  • MCP Hosts: Programs like Claude Desktop, IDEs, or AI tools that want to access data through MCP. Hosts can run multiple clients;
  • MCP Clients: Protocol clients that maintain 1:1 connections with servers;
  • MCP Servers: Lightweight programs exposing specific capabilities through the standardized Model Context Protocol — for example, a pizza delivery API or a train booking service.

For instance, when you use Claude Desktop (the host), you can configure it to interact with your application (the server). Claude Desktop may have many clients interacting with your application.

In this article, we’ll focus on the server side, where the most interesting things happen because it’s your application — the space for creativity! 🚀

Section intitulée transportsTransports

How does the client communicate with the server? There are two methods:

Section intitulée stdioSTDIO

The client spawns the server as a child process and communicates through standard input/output (STDIN/STDOUT):

MCP - transport - stdio

Section intitulée http-with-server-sent-eventsHTTP with Server-Sent Events

The client connects to the server via HTTP, and the server sends events back:

MCP - transport - HTTP

Section intitulée messagingMessaging

The client-server connection is stateful and persistent. While this approach might not align with serverless architecture, it enables interesting use cases.

Once the client connects to the server, it can send requests. The server processes the request and sends back a response. The client may then send another request. Additionally, the server can send notifications, maintaining the persistent connection.

Messages follow the JSON-RPC 2.0 standard:

--> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}
<-- {"jsonrpc": "2.0", "result": 19, "id": 1}
Learn more about types of messages ⬇

Section intitulée types-of-messagesTypes of Messages

  1. Request: The client sends a request to the server.

    {
        jsonrpc: "2.0";
        id: string | number;
        method: string;
        params?: {
            [key: string]: unknown;
        };
    }
  2. Response: The server sends a response to the client.

    {
        jsonrpc: "2.0";
        id: string | number;
        result?: {
            [key: string]: unknown;
        };
        error?: {
            code: number;
            message: string;
            data?: unknown;
        }
    }
  3. Notification: The server pushes a notification to the client.

    {
        jsonrpc: "2.0";
        method: string;
        params?: {
            [key: string]: unknown;
        };
    }

Section intitulée lifecycleLifecycle

The connection lifecycle:

MCP - transport - lifecycle

Section intitulée initialization-phaseInitialization Phase

During this phase:

  • Protocol version compatibility is established;
  • Capabilities are exchanged and negotiated;
  • Implementation details are shared.
Learn more about request / response shape ⬇

Client initialize request example:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "roots": {
        "listChanged": true
      },
      "sampling": {}
    },
    "clientInfo": {
      "name": "ExampleClient",
      "version": "1.0.0"
    }
  }
}

Server initialize response example:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "logging": {},
      "prompts": {
        "listChanged": true
      },
      "resources": {
        "subscribe": true,
        "listChanged": true
      },
      "tools": {
        "listChanged": true
      }
    },
    "serverInfo": {
      "name": "ExampleServer",
      "version": "1.0.0"
    }
  }
}

Section intitulée operation-phaseOperation Phase

During this phase, the client executes methods exposed by the server. The server may also send notifications.

Section intitulée shutdownShutdown

The client can disconnect, closing the connection.

Section intitulée what-can-a-server-doWhat Can a Server Do?

Servers provide the foundational building blocks for adding context to language models via MCP. These primitives enable rich interactions between clients, servers, and language models:

  • Prompts: Predefined templates or instructions that guide language model interactions. These are initiated by the user. Examples include slash commands and menu options;
  • Resources: Structured data or content that provides additional context to the model. Examples include file contents and git history;
  • Tools: Executable functions that allow models to perform actions or retrieve information.

Detailing all of these would make the article too lengthy. Hence, we’ll focus on Tools, the most intriguing and powerful aspect of the protocol.

Section intitulée how-to-implement-tools-in-an-mcp-serverHow to Implement Tools in an MCP Server

At JoliCode, we primarily use PHP and Symfony, so we’ll demonstrate how to implement an MCP server in PHP using Symfony. For simplicity, we’ll utilize the stdio transport.

Our server will support two actions:

  1. Listing all current Symfony commands;
  2. Executing a Symfony command.

As of now, no official PHP library exists for MCP. We attempted to use logiscape/mcp-sdk-php but without success. Therefore, we’ll implement the protocol ourselves.

Note: This is a proof of concept (POC) and not production-ready code ⚠

We’ll use a Symfony command to start the server:

namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
    name: 'mcp',
    description: 'Starts an MCP server',
)]
class McpCommand extends Command
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
    }
}

Section intitulée reading-input-from-stdinReading Input from STDIN

The first step is to read the content from STDIN, buffer it, and decode it:

protected function execute(InputInterface $input, OutputInterface $output): int
{
    $buffer = '';

    while (true) {
        $line = fgets(STDIN);
        if (false === $line) {
            usleep(1000);
            continue;
        }
        $buffer .= $line;
        if (str_contains($buffer, "\n")) {
            $lines = explode("\n", $buffer);
            $buffer = array_pop($lines);
            foreach ($lines as $line) {
                $this->processLine($output, $line);
            }
        }
    }

    return Command::SUCCESS;
}

Section intitulée processing-the-inputProcessing the Input

We decode the JSON payload, invoke the appropriate method, and return the response:

private function processLine(OutputInterface $output, string $line): void
{
    try {
        $payload = json_decode($line, true, JSON_THROW_ON_ERROR);

        $method = $payload['method'] ?? null;

        $response = match ($method) {
            'initialize' => $this->sendInitialize(),
            'tools/list' => $this->sendToolsList(),
            'tools/call' => $this->callTool($payload['params']),
            'notifications/initialized' => null,
            default => $this->sendProtocolError(sprintf('Method "%s" not found', $method)),
        };
    } catch (\Throwable $e) {
        $response = $this->sendApplicationError($e);
    }

    if (!$response) {
        return;
    }

    $response['id'] = $payload['id'] ?? 0;
    $response['jsonrpc'] = '2.0';

    $output->writeln(json_encode($response));
}

Section intitulée listing-toolsListing Tools

Here is the tools/list method, which provides a list of available tools:

private function sendToolsList(): array
{
    return [
        'result' => [
            'tools' => [
                [
                    'name' => 'list_commands',
                    'description' => 'List all Symfony commands.',
                    'inputSchema' => [
                        'type' => 'object',
                        '$schema' => 'http://json-schema.org/draft-07/schema#',
                    ],
                ],
                [
                    'name' => 'call_command',
                    'description' => 'Call a Symfony command.',
                    'inputSchema' => [
                        'type' => 'object',
                        'properties' => [
                            'command_name' => ['type' => 'string'],
                        ],
                        'required' => ['command_name'],
                        'additionalProperties' => false,
                        '$schema' => 'http://json-schema.org/draft-07/schema#',
                    ],
                ],
            ],
        ],
    ];
}

As you can see, we have two tools: list_commands and call_command. The inputSchema is a JSON schema that describes the tool’s input. It’ll document how the LLM can use it.

The more descriptive you are, the better it is for the LLM! It’s like the SEO 😎! (Here, descriptions are really too shorts 🙈)

You can add as many tools as you want. By the way, instead of these two tools, we could have added one tool per Symfony command (just noticed that while writing this article 🤯). I think it would have been better, because we could have typed all command arguments / options. Anyway, let’s continue.

Typically, the request would be:

{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "tools/list",
  "params": {}
}

And the response would be:

{
  "result": {
    "tools": [
      {
        "name": "list_commands",
        "description": "List all symfony commands.",
        "inputSchema": {
          "type": "object",
          "$schema": "http://json-schema.org/draft-07/schema#"
        }
      },
      {
        "name": "call_command",
        "description": "Call a symfony command.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "command_name": {
              "type": "string"
            }
          },
          "required": [
            "command_name"
          ],
          "additionalProperties": false,
          "$schema": "http://json-schema.org/draft-07/schema#"
        }
      }
    ]
  },
  "id": 4,
  "jsonrpc": "2.0"
}

Section intitulée handling-tool-requestsHandling Tool Requests

Next, we define how to handle a tool call:

private function callTool(array $params): array
{
    $name = $params['name'];
    $arguments = $params['arguments'] ?? [];

    return match ($name) {
        'list_commands' =>  $this->callCommand('list', ['--format' => 'md']),
        'call_command' => $this->callCommand($arguments['command_name']),
        default => $this->sendProtocolError(sprintf('Tool "%s" not found', $name)),
    };
}

private function callCommand(string $commandName, array $parameters = []): array
{
    $command = $this->getApplication()->find($commandName);

    $input = new ArrayInput(['command' => $command, ...$parameters]);
    $output = new BufferedOutput();

    $command->run($input, $output);

    return [
        'result' => [
            'content' => [
                [
                    'type' => 'text',
                    'text' => $output->fetch(),
                ],
            ],
        ],
    ];
}

Here we go, we have implemented the two tools. The list_commands tool will list all Symfony commands, and the call_command tool will call a Symfony command.

If the LLM decided to clear the cache, the request would be:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "_meta": {
      "progressToken": 0
    },
    "name": "call_command",
    "arguments": {
      "command_name": "cache:clear"
    }
  }
}

And the response would be:

{
  "result": {
    "content": [
      {
        "type": "text",
        "text": "\n // Clearing the cache for the dev environment with debug true                  \n\n [OK] Cache for the \"dev\" environment (debug=true) was successfully cleared.    \n\n"
      }
    ]
  },
  "id": 2,
  "jsonrpc": "2.0"
}

Section intitulée demo-timeDemo time!

To use our new server, we need to configure it in a host. We’ll use Claude Desktop.

In Claude Desktop’s configuration, we can add as many servers as we want:

// ~/.config/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "symfony_app": {
      // We don’t run PHP directly to have better debugging. We’ll talk about it later
      "command": "/home/gregoire/dev/labs/symfony/mcp/bin/mcp.sh"
    },
    // Another server, for example a filesystem server to explore a folder
    "filesystem": {
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-filesystem",
        "/home/gregoire/Downloads/video"
      ]
    }
  }
}

Then you can chat with it like usual:

MCP - demo - list all commands

MCP - demo - clear cache

MCP - demo - about

Section intitulée debuggingDebugging

Debugging can be tricky without the right tooling! Here are some tips:

You can use the stderr to log errors but this is not useful. You can also use the logging capability to send logs to the client. But when nothing works, well, logging does not work either.

I created this small bash wrapper to log everything (STDIN, STDOUT, STERR) to some files to see what’s going on:

#!/bin/bash
#mcp.sh


set -e
set -o pipefail

BASE=$(dirname $0)/..

date >> "$BASE/run.log"

stdin_log="$BASE/stdin.log"
stdout_log="$BASE/stdout.log"
stderr_log="$BASE/stderr.log"

tee -a "$stdin_log" | \
  $BASE/bin/console mcp > >(tee -a "$stdout_log") 2> >(tee -a "$stderr_log" >&2)

Additionally, the MCP Inspector is invaluable:

npx @modelcontextprotocol/inspector

MCP inspector

Section intitulée security-considerationsSecurity Considerations

Each time Claude Desktop connects to a server to execute a tool, it prompts the user to accept the connection. While this is a great security measure, exposing tools like database management requires caution to avoid unintended consequences.

Section intitulée conclusionConclusion

I do think building an LLM agent is the next big step in generative IA. The agent must be able to exchange information with an external system, and perform some actions with them such as pushing code to GitHub, booking a train, getting the weather forecast, or even controlling your house.

I also believe in standard and open source, and I really hope this standard will be adopted by all major actors such as OpenAI, Google, etc.

If all conditions are reunited, we will be able to do amazing things. At some point we could even avoid building a frontend. An API over MCP would be enough.

But we should not forget about skynet! 😀

Finally, since there is no official PHP SDK on the market, we would like to build one. Would it be interesting to try it?

(Read the full McpCommand code on github.com)

Commentaires et discussions