This tutorial will guide you through using the DotApp PHP framework to build an MCP (Machine Control Protocol) server for sending emails. We'll cover setting up the framework, creating an MCP server module, and configuring it to handle email operations. A step-by-step video tutorial in English is also available to help you follow along!
What is DotApp?
DotApp is a lightweight, modular PHP framework designed for rapid development of web applications and APIs. It emphasizes simplicity, flexibility, and developer productivity, making it an excellent choice for building custom solutions like an MCP server for AI-driven tasks.
In this article, we'll create an MCP server that allows an AI agent to send emails using the DotApp framework. Let's dive in!
Step 1: Installing the DotApp PHP Framework
To get started, we need to install the DotApp framework using the dotapper.php
installer.
-
Download the Installer:
- Visit https://install.dotapp.dev/dotapper.php.
- On Linux, use:
wget https://install.dotapp.dev/dotapper.php
Install and Update DotApp:
Run the following commands to install and update the framework:
php dotapper.php --install
php dotapper.php --update
Alternative Installation Methods:
-
Using Composer:
composer create-project dotsystems/dotapp ./
-
Using Git:
git clone https://github.com/dotsystems-sk/dotapp.git ./
This sets up the DotApp framework in your project directory.
Step 2: Creating the MCP Module and Controller
Next, we'll create a module named MCPtest
and a controller MCP
to handle MCP server logic.
Run these commands:
php dotapper.php --create-module=MCPtest
php dotapper.php --module=MCPtest --create-controller=MCP
This creates:
- A module named
MCPtest
. - A controller named
MCP
withinapp/modules/MCPtest/Controllers/MCP.php
.
Step 3: Configuring Email Settings
In the configuration file app/config.php
, add the email settings for SMTP and IMAP. The passwords here are placeholders (already changed for security).
Config::email("testAcc", "smtp", [
"host" => "server1.dotsystems.sk",
"port" => 587,
"timeout" => 30,
"secure" => "tls",
"username" => "testsender@858.eu",
"password" => "U4jNaM7H",
"from" => "testsender@858.eu"
]);
Config::email("testAcc", "imap", [
"host" => "server1.dotsystems.sk",
"port" => 993,
"timeout" => 30,
"secure" => "ssl",
"username" => "testsender@858.eu",
"password" => "U4jNaM7H"
]);
These settings configure the email account testAcc
for sending and receiving emails.
Step 4: Implementing the MCP Controller
Edit the controller file app/modules/MCPtest/Controllers/MCP.php
to handle MCP requests and email sending functionality.
<?php
namespace Dotsystems\App\Modules\MCPtest\Controllers;
use Dotsystems\App\DotApp;
use Dotsystems\App\Parts\Middleware;
use Dotsystems\App\Parts\Response;
use Dotsystems\App\Parts\Renderer;
use Dotsystems\App\Parts\Router;
use Dotsystems\App\Parts\Email;
use Dotsystems\App\Parts\MCP as DotAppMCP;
class MCP extends \Dotsystems\App\Parts\Controller {
public static function runMCP($request) {
return json_encode(DotAppMCP::execute($request));
}
public static function addTools() {
DotAppMCP::addTool(
'send_email',
'Send an email to a specified recipient',
[
"to_email" => ["type" => 'string', "description" => "Recipient email address"],
"subject" => ["type" => 'string', "description" => "Email subject"],
"body" => ["type" => 'string', "description" => "HTML BODY of email"],
"save" => ["type" => 'boolean', "description" => "Save to Sent folder? If user do not specify, use default: false"],
],
function($params) {
self::sendEmail($params['to_email'], $params['subject'], $params['body'], $params['save']);
}
);
}
public static function sendEmail($to, $subject, $body, $save) {
DotApp::DotApp()->unprotect($to);
DotApp::DotApp()->unprotect($subject);
DotApp::DotApp()->unprotect($body);
if ($save === false) {
$result = Email::send("testAcc", $to, $subject, $body, null);
}
if ($save === true) {
$result = Email::sendAndSave("Sent", "testAcc", $to, $subject, $body, null);
}
if ($result === true) {
return [
"status" => "success",
"message" => "Email sent to " . $to
];
} else {
throw new \Exception("Email was not sent: " . implode(",", $result));
}
}
}
This code:
- Defines an MCP tool
send_email
with parameters for recipient, subject, body, and a save option. - Implements the
sendEmail
method to send emails using the configuredtestAcc
account. - Uses
runMCP
to process MCP requests and return JSON responses.
Step 5: Setting Up Routes
In app/modules/MCPtest/module.init.php
, initialize the module, add the MCP tools, and define a route to handle MCP requests.
<?php
namespace Dotsystems\App\Modules\MCPtest;
use \Dotsystems\App\DotApp;
use \Dotsystems\App\Parts\Router;
use \Dotsystems\App\Parts\Middleware;
use \Dotsystems\App\Parts\Request;
use \Dotsystems\App\Parts\Response;
use \Dotsystems\App\Parts\Input;
use \Dotsystems\App\Parts\DB;
use \Dotsystems\App\Parts\Renderer;
class Module extends \Dotsystems\App\Parts\Module {
public function initialize($dotApp) {
Controllers\MCP::addTools();
Router::post("/", 'MCPtest:MCP@runMCP', Router::STATIC_ROUTE);
}
public function initializeRoutes() {
return ['/'];
}
public function initializeCondition($routeMatch) {
return $routeMatch;
}
}
new Module($dotApp);
This sets up a POST route (/
) that directs all MCP requests to the runMCP
method in the MCP
controller.
Step 6: Testing the MCP Server
To test the MCP server, you can use the following PHP agent script that interacts with an AI (e.g., ChatGPT) to send MCP requests. The script includes an HTML form for user interaction and requires an OpenAI API key, which users can provide via environment variables.
<?php
// Set script execution time limit to 120 seconds
set_time_limit(120);
// Define OpenAI API key and endpoint (use environment variables in production)
define('OPENAI_API_KEY', getenv('OPENAI_API_KEY') ?: '');
define('API_URL', 'https://api.openai.com/v1/chat/completions');
// Check if a string is valid JSON
function isJson($string) {
json_decode($string);
return json_last_error() === JSON_ERROR_NONE;
}
// Extract JSON from a string that may contain text
function extractJsonFromResponse($response) {
// Try to parse as JSON directly
if (isJson($response)) {
return json_decode($response, true);
}
// Extract JSON from text using regex
$pattern = '/\{[\s\S]*?\}(?=\s*$|\s*[^[{])/';
if (preg_match($pattern, $response, $matches)) {
$jsonString = $matches[0];
if (isJson($jsonString)) {
return json_decode($jsonString, true);
}
}
return null;
}
// Make a cURL request (GET or POST)
function makeCurlRequest($url, $postData = null) {
try {
$ch = curl_init($url);
$headers = [
'User-Agent: Mozilla/5.0 (compatible; CustomBot/1.0)',
'Content-Type: application/json'
];
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
if ($postData) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
}
$response = curl_exec($ch);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
return ['error' => "cURL error: $error"];
}
curl_close($ch);
return ['content' => trim(preg_replace('/\s+/', ' ', $response))];
} catch (Exception $e) {
return ['error' => "Error making cURL request: " . $e->getMessage()];
}
}
// Fetch content from a URL (GET or POST)
function fetchUrlContent($url, $postData = null) {
$result = makeCurlRequest($url, $postData);
if (isset($result['error'])) {
return ['error' => "Failed to fetch content from URL: $url - {$result['error']}"];
}
$content = $result['content'];
if (isJson($content)) {
$jsonData = json_decode($content, true);
if (isset($jsonData['error'])) {
return ['error' => "JSON-RPC error: {$jsonData['error']['message']} (code: {$jsonData['error']['code']})"];
}
return ['content' => $jsonData];
}
return ['content' => $content];
}
// Send request to ChatGPT API
function callChatGpt($prompt, $conversation = []) {
$ch = curl_init(API_URL);
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . OPENAI_API_KEY
];
// System prompt for MCP handshake and JSON-RPC requests
$systemPrompt = 'You reply in the language the user starts communicating with you in the first message. You are a friendly and conversational AI assistant designed to assist users with general questions and tasks. Your primary role is to engage in natural conversation, answer questions to the best of your knowledge, and provide helpful responses without assuming any specific tools or external services unless explicitly requested by the user.
**Detection and Storage**: Search the entire conversation history for an MCP server URL in any message. Look for patterns such as "use mcp server at <url>", "mcp at <url>", "use <url>", "Pouzi mcp server: <url>", or any URL containing "mcp" in the domain (e.g., https://mcp.dotapp.dev). Use robust matching to recognize variations in language and format (e.g., case-insensitive, with or without colons, spaces, or "at"). Once an MCP server URL is found, store it for the entire conversation and reuse it for all subsequent tasks requiring an MCP server, unless the user explicitly provides a new MCP server URL. Validate the URL to ensure it is a valid HTTPS URL before storing it. If no MCP server URL is found in the history, return: {"error": "I cannot perform this task without an MCP server. Please provide an MCP server URL (e.g., \'use mcp server at https://mcp.example.com\')"}
If the user requests a task that requires external data or services (e.g., checking the weather, sending an email), or mentions an MCP server (e.g., "use mcp server at <url>", "mcp at <url>", or a URL containing "mcp"), follow these rules:
1. **MCP Server Detection and Tool Discovery**: If the user provides an MCP server URL (e.g., "use mcp server at https://mcp.example.com" or mentions a URL containing "mcp"), immediately extract the MCP server URL from the input. Generate a JSON-RPC 2.0 request for the `initialize` method to negotiate protocol version and capabilities, followed by a `tools/list` request to retrieve the list of available tools from the MCP server. Store the list of tools and their parameters for use in subsequent interactions. Ensure that the `data` parameter in any `tools/call` request is always set (use an empty string if no data is required). Return **only** pure, valid JSON without any additional text, comments, markdown, or code fences for these requests. Example for `initialize`:
{"action": "fetch", "url": "<mcp_server_url>", "data": {"jsonrpc": "2.0", "id": "<unique_id>", "method": "initialize", "params": {"clientInfo": {"name": "mcp-client", "version": "1.0"}, "protocolVersion": "2025-06-18", "capabilities": {"tools": {"list": true, "call": true}, "resources": {"list": true, "read": true}, "prompts": {"list": true, "get": true}}}}}
Example for `tools/list`:
{"action": "fetch", "url": "<mcp_server_url>", "data": {"jsonrpc": "2.0", "id": "<unique_id>", "method": "tools/list", "params": {}}}
So once you run initialize method, then you must also run tools/list or tools/call method to get the list of available tools - based on last reply from the initialize method.
2. **Tool Usage**: After retrieving the list of available tools, use them automatically for tasks that match their capabilities (e.g., if the user asks to "fetch a web page" and the MCP server supports a `fetch_url` tool, use it). Ensure all required parameters for the tool are provided, and the `data` parameter is always set (use an empty string if optional and not provided). Generate a JSON-RPC 2.0 request for `tools/call` with appropriate parameters. Example:
{"action": "fetch", "url": "<mcp_server_url>", "data": {"jsonrpc": "2.0", "id": "<unique_id>", "method": "tools/call", "params": {"name": "fetch_url", "arguments": {"url": "<target_url>", "method": "GET", "data": ""}}}}
3. **Verification of Capabilities**: If the user asks whether a specific task can be performed using an MCP server (e.g., "Can you send an email using https://mcp.example.com?"), check the stored list of tools from the `tools/list` response to determine if the requested capability is supported. Return a conversational response indicating whether the task is supported (e.g., "Yes, the MCP server at https://mcp.example.com supports sending emails" or "No, the MCP server at https://mcp.example.com does not support sending emails"). Do not execute any actions (e.g., `tools/call`) unless explicitly instructed by the user.
4. **Action Execution**: Execute an action (e.g., `tools/call`, `resources/read`, or `prompts/get`) **only** if the user explicitly requests a specific task using the MCP server. Use the stored tool information to validate parameters and ensure the `data` parameter is always set (use an empty string if optional and not provided). Return **only** pure, valid JSON without any additional text or markdown for these requests. Example:
{"action": "fetch", "url": "<mcp_server_url>", "data": {"jsonrpc": "2.0", "id": "<unique_id>", "method": "tools/call", "params": {"name": "tool_name", "arguments": {}}}}
5. **Error Handling**: If a JSON-RPC response contains an `error` field (e.g., code -32600, -32601, -32602), return **only** a JSON with the error: {"error": "<error_message> (code: <error_code>)"}. If no MCP server is provided for a task requiring external data, return: {"error": "I cannot perform this task without an MCP server. Please provide an MCP server URL (e.g., \'use mcp server at https://mcp.example.com\')."} Do not add any additional text or markdown.
6. **Non-MCP Tasks**: For tasks that do not require an MCP server or external data (e.g., general knowledge questions, casual conversation), respond directly with natural, conversational text. Do not assume any MCP server or external tools unless explicitly mentioned by the user.
**Important**: Never assume a default MCP server URL. Always extract the MCP server URL from the user input, if not found extract it from history. Never use GET for MCP requests; always use POST with JSON-RPC 2.0 format. Never execute any actions on an MCP server (e.g., `tools/call`) unless the user explicitly requests the action. Ensure all MCP-related responses are **pure, valid JSON** without any additional text, comments, markdown, or code fences (e.g., ```
json). Ensure all requests include `jsonrpc: "2.0"`, a unique `id`, and correct `method` and `params`.
**JSON Validity for FETCH Actions**: When generating JSON for `fetch` actions, ensure the JSON is valid ! You must always use VALID JSON syntax. Force to check JSON syntax before returning the response.';
$messages = [
['role' => 'system', 'content' => $systemPrompt]
];
// Add conversation history
foreach ($conversation as $msg) {
$messages[] = [
'role' => $msg['role'],
'content' => $msg['content']
];
}
// Add current user prompt
$messages[] = ['role' => 'user', 'content' => $prompt];
$data = [
'model' => 'gpt-4.1-mini',
'messages' => $messages,
'temperature' => 0.7
];
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
$response = curl_exec($ch);
if (curl_errno($ch)) {
curl_close($ch);
return ['error' => 'cURL error: ' . curl_error($ch)];
}
curl_close($ch);
$responseData = json_decode($response, true);
if (isset($responseData['error'])) {
return ['error' => 'API error: ' . $responseData['error']['message']];
}
return [
'content' => $responseData['choices'][0]['message']['content'],
'role' => 'assistant'
];
}
// Initialize conversation history
$conversation = [];
$finalResponse = '';
// Load conversation history from hidden input (if exists)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['conversation_history'])) {
$historyJson = $_POST['conversation_history'];
if (isJson($historyJson)) {
$conversation = json_decode($historyJson, true);
// Validate conversation structure
if (!is_array($conversation)) {
$conversation = [];
$finalResponse = "<p class='text-red-500'>Invalid conversation history format.</p>";
} else {
// Ensure all messages have required fields
foreach ($conversation as &$msg) {
if (!isset($msg['role']) || !isset($msg['content']) || !isset($msg['display'])) {
$conversation = [];
$finalResponse = "<p class='text-red-500'>Corrupted conversation history.</p>";
break;
}
}
}
} else {
$finalResponse = "<p class='text-red-500'>Invalid conversation history JSON.</p>";
}
}
// Main script logic
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['prompt'])) {
$userInput = trim($_POST['prompt']);
if (!empty($userInput)) {
$currentPrompt = $userInput;
// Add user input to conversation
$conversation[] = ['role' => 'user', 'content' => $userInput, 'display' => true];
while (true) {
// Send current prompt to the assistant
$response = callChatGpt($currentPrompt, $conversation);
if (isset($response['error'])) {
$finalResponse = $response['error'];
$conversation[] = ['role' => 'assistant', 'content' => $finalResponse, 'display' => true];
break;
}
$assistantResponse = $response['content'];
// Try to extract JSON from response
$jsonResponse = extractJsonFromResponse($assistantResponse);
if ($jsonResponse !== null) {
if (isset($jsonResponse['error'])) {
$finalResponse = $jsonResponse['error'];
$conversation[] = ['role' => 'assistant', 'content' => $finalResponse, 'display' => true];
break;
}
if (isset($jsonResponse['action']) && $jsonResponse['action'] === 'fetch') {
$url = $jsonResponse['url'];
$postData = isset($jsonResponse['data']) ? $jsonResponse['data'] : null;
// Validate JSON-RPC structure
if ($postData && (!isset($postData['jsonrpc']) || $postData['jsonrpc'] !== '2.0' ||
!isset($postData['id']) || !isset($postData['method']) || !isset($postData['params']))) {
$finalResponse = "Invalid JSON-RPC request generated by assistant";
$conversation[] = ['role' => 'assistant', 'content' => $finalResponse, 'display' => true];
break;
}
// Fetch URL content
$urlContent = fetchUrlContent($url, $postData);
if (isset($urlContent['error'])) {
$finalResponse = $urlContent['error'];
$conversation[] = ['role' => 'assistant', 'content' => $finalResponse, 'display' => true];
break;
}
// Add fetch response to conversation history (hidden from UI)
$conversation[] = ['role' => 'assistant', 'content' => $assistantResponse, 'display' => false];
// Add fetched content as a hidden user prompt
$currentPrompt = "You have sent action:fetch request JSON to load external url. Here is reply:\n" . json_encode($urlContent['content']);
$conversation[] = ['role' => 'user', 'content' => $currentPrompt, 'display' => false];
continue; // Continue loop with new prompt
}
}
// If response is not a valid JSON, treat as final response
$finalResponse = $assistantResponse;
$conversation[] = ['role' => 'assistant', 'content' => $assistantResponse, 'display' => true];
break;
}
} else {
$finalResponse = "<p class='text-red-500'>Please enter a valid input.</p>";
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChatGPT with MCP and JSON-RPC Processing</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
<div class="max-w-2xl w-full mx-auto p-6 bg-white rounded-lg shadow-lg">
<h1 class="text-2xl font-bold text-gray-800 mb-6 text-center">ChatGPT 4.1 Mini</h1>
<form method="post" class="space-y-4">
<textarea
name="prompt"
rows="5"
class="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y"
placeholder="Enter your question or MCP request (e.g., 'Use mcp server at https://mcp.example.com to get weather for London')..."
></textarea>
<input
type="hidden"
name="conversation_history"
value="<?php echo htmlspecialchars(json_encode($conversation, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), ENT_QUOTES, 'UTF-8'); ?>"
>
<button
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors duration-200"
>
Submit
</button>
</form>
<?php if (!empty($conversation)): ?>
<div class="mt-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-800">Conversation:</h2>
<?php foreach (array_reverse($conversation) as $msg): ?>
<?php if ($msg['display']): ?>
<div class="p-4 rounded-lg <?php echo $msg['role'] === 'user' ? 'bg-blue-100' : 'bg-gray-200'; ?>">
<p class="font-semibold"><?php echo $msg['role'] === 'user' ? 'You' : 'Assistant'; ?>:</p>
<p><?php echo nl2br(htmlspecialchars($msg['content'], ENT_QUOTES, 'UTF-8')); ?></p>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($finalResponse) && empty($conversation)): ?>
<div class="mt-6">
<?php echo $finalResponse; ?>
</div>
<?php endif; ?>
</div>
</body>
</html>
Testing the MCP Server
Set Up the API Key
Configure the OPENAI_API_KEY environment variable with your OpenAI API key.
Run the Agent
Execute the script and interact with the AI assistant via the provided HTML form.
Example Commands
Send an Email
To send an email, use the command:
"Using the MCP server at https://your-mcp-server-url, send an email to the address xyz@domain.com with the text AAABBB."
The assistant will prompt for the MCP server URL if not provided, or you can include it directly:
"Use the MCP server at https://your-mcp-server-url."
List Available Tools
To display the available tools of the MCP server, use:
"Display the available tools of the MCP server at https://your-mcp-server-url."
This will show the tools available, such as send_email.
Watch the Video Tutorial
For a complete, step-by-step guide in English, including how to install the DotApp PHP framework and set up the MCP server, check out this YouTube video tutorial: https://youtu.be/ED6ubqbJZ3c.
Conclusion
With the DotApp PHP framework, creating an MCP server for AI-driven email sending is straightforward. By following these steps, you can set up a robust server, define custom tools, and integrate with AI agents seamlessly. The modular design of DotApp allows you to extend the MCP server with additional tools as needed.
Try it out, and let me know in the comments how it worked for you! For more details on DotApp, visit https://dotapp.dev.
Happy coding!
Top comments (0)