projectcues/php-mcp-server
If you are the rightful owner of php-mcp-server and would like to certify it and/or have it hosted online, please leave a comment on the right or send an email to henry@mcphub.com.
This repository provides a minimal, shared-hosting-friendly MCP server that exposes tools to LLM clients via JSON-RPC.
MCP Server (Plug-and-Play)
This repository provides a minimal, shared-hosting-friendly MCP server that exposes tools to LLM clients via JSON-RPC. It is designed to be plug-and-play: drop in tool classes under src/Tools/
, and they are auto-discovered and exposed.
Quick Start
- Requirements:
PHP >= 8.0
,Composer
, Apache or Nginx (Apache.htaccess
included for shared hosting). - Install dependencies:
composer install
- No server configuration file required. Tools accept credentials per call.
- Deploy
public/
to your web root. Requests route topublic/index.php
. - Call with JSON-RPC over HTTP. Use the sample
curl
commands below.
Plug-and-Play Design
- Auto-discovery: All classes under the
MCP\Tools
namespace insrc/Tools/
are discovered and registered. - Flexible instantiation: Tools can be constructed using any of these supported patterns:
public static function create(MCP\Framework\ToolContext $context): self
__construct(MCP\Framework\ToolContext $context)
__construct(GuzzleHttp\Client $client, string $apiKey)
(backward-compatible)__construct()
(no-arg constructor)
- Context:
ToolContext
provides configuration and HTTP client factories for services.
Writing a Tool
-
Create a PHP file in
src/Tools/
and define one or more tool classes under theMCP\Tools
namespace. -
Extend
MCP\Framework\Tool
and implement:getName(): string
getDescription(): string
getInputSchema(): array
(JSON Schema fortools/call
arguments)execute(array $arguments): array
-
Return results as text objects for MCP:
return ['type' => 'text', 'text' => 'Your result text'];
- Choose an instantiation pattern:
Option A: Static factory with ToolContext
use MCP\Framework\ToolContext;
use MCP\Framework\Tool;
class WeatherTool extends Tool {
private \GuzzleHttp\Client $client;
public static function create(ToolContext $ctx): self {
$inst = new self();
$inst->client = $ctx->makeServiceClient('weather', ['timeout' => 8.0]);
return $inst;
}
public function getName(): string { return 'weather_current'; }
public function getDescription(): string { return 'Get current weather by city.'; }
public function getInputSchema(): array {
return {
'type': 'object',
'properties': {
'city': { 'type': 'string' },
// If API requires a key, declare and pass per-call:
// 'api_key': { 'type': 'string' }
},
'required': ['city']
};
}
public function execute(array $args): array {
$resp = $this->client->get('current', [
'query' => [ 'q' => $args['city'] /*, 'key' => $args['api_key'] */ ]
]);
return ['type' => 'text', 'text' => $resp->getBody()->getContents()];
}
}
Option B: Constructor with ToolContext
use MCP\Framework\ToolContext;
use MCP\Framework\Tool;
class GithubRepoTool extends Tool {
public function __construct(private ToolContext $ctx) {}
public function getName(): string { return 'github_repo_info'; }
public function getDescription(): string { return 'Fetch GitHub repo details.'; }
public function getInputSchema(): array { return ['type' => 'object', 'properties' => ['token' => ['type' => 'string'], 'owner' => ['type' => 'string'], 'repo' => ['type' => 'string']], 'required' => ['token','owner','repo']]; }
public function execute(array $args): array {
$client = $this->ctx->makeServiceClient('github');
$resp = $client->get("repos/{$args['owner']}/{$args['repo']}", [ 'headers' => ['Authorization' => 'Bearer ' . $args['token']] ]);
return ['type' => 'text', 'text' => $resp->getBody()->getContents()];
}
}
ToolContext Reference
get(string $key, $default = null)
: Read from context settings provided by the server (no config file).getAll(): array
: Entire context settings array.makeClient(array $options = []): Client
: Create a default HTTP client using provided options.makeServiceClient(string $service, array $options = []): Client
: Create client using service-specific options (for examplebase_uri
,headers
).
Options merge: Supplied $options
override defaults; headers are merged.
Server Endpoints
POST /index.php
JSON-RPC methods:initialize
tools/list
tools/call
with{ name, arguments }
GET /index.php?health=1
returns{ "status": "ok" }
.
CORS
- Browser clients can call the server directly. The entrypoint sets:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
- Preflight (
OPTIONS
) requests return204
with the headers above.
Sample Requests
Initialize
curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"1","method":"initialize","params":{}}' \
https://your-host/index.php
List Tools
curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"2","method":"tools/list","params":{}}' \
https://your-host/index.php
Call Tool (VBOUT examples include per-call api_key
)
curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"vbout_get_contacts","arguments":{"api_key":"$VBOUT_KEY","limit":10}}}' \
https://your-host/index.php
GitHub (requires token)
curl -s -X POST \
-H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0",
"id":"4",
"method":"tools/call",
"params":{
"name":"github_repo_info",
"arguments":{ "token":"$GITHUB_TOKEN", "owner":"octocat", "repo":"Hello-World" }
}
}' \
https://your-host/index.php
Configuration
No config.php
is required. Tools should set service base URIs explicitly when constructing clients, and credentials must be passed per call (for example api_key
).
Shared Hosting Tips
.htaccess
included: disables directory listing and routes toindex.php
.- Logging: errors are written to
storage/error.log
. - Rate limiting and payload size guard enabled in
public/index.php
. - Avoid storing real secrets in the repo; pass credentials per call.
Development Notes
- Autoload configured via
composer.json
(MCP\
->src/
). - Tool discovery is cached to reduce filesystem and reflection overhead.
- Response payloads follow JSON-RPC 2.0 and return
result.content
as text for LLM-friendly output. - Where tool responses are JSON, tools now parse bodies and return structured
{"type":"json","data":{...}}
when available. If parsing fails, tools fall back to{"type":"text","text":"..."}
. - VBOUT tools:
- Use per-call
api_key
credentials passed inarguments
. - Hardened HTTP clients with explicit
base_uri
, timeouts, and JSON headers. - Standardized error handling: HTTP 4xx/5xx responses surface messages from JSON when present; otherwise show the raw HTTP status or body.
- Use per-call
Connect an LLM
- Endpoint:
POST https://your-host/index.php
using JSON-RPC 2.0. - Handshake: call
initialize
, thentools/list
to discover available tools. - Calls: use
tools/call
with{ name, arguments }
. Pass per-call credentials likeapi_key
ortoken
inarguments
when required. - Results: server returns
result.content
as an array of objects such as{"type":"text","text":"..."}
or{"type":"json","data":{...}}
. - Errors: standard JSON-RPC codes are used; server may respond with
-32000
for tool execution failures and HTTP 4xx for guards (401/405/413/429).
Node.js Example
// Minimal JSON-RPC client for MCP server
const fetch = globalThis.fetch || (await import('node-fetch')).default;
const HOST = 'https://your-host/index.php';
async function rpc(method, params) {
const body = { jsonrpc: '2.0', id: String(Date.now()), method, params };
const res = await fetch(HOST, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const json = await res.json();
if (json.error) throw new Error(`${json.error.code}: ${json.error.message}`);
return json.result;
}
// Initialize and list tools
const init = await rpc('initialize', {});
const tools = await rpc('tools/list', {});
console.log('Tools:', tools.tools.map(t => t.name));
// Call VBOUT contacts (per-call api_key)
const contacts = await rpc('tools/call', {
name: 'vbout_get_contacts',
arguments: { api_key: process.env.VBOUT_KEY, limit: 10 },
});
console.log('Contacts result:', contacts.content[0]);
// Call GitHub repo info (per-call token)
const gh = await rpc('tools/call', {
name: 'github_repo_info',
arguments: { token: process.env.GITHUB_TOKEN, owner: 'octocat', repo: 'Hello-World' },
});
console.log('GitHub result:', gh.content[0]);
Python Example
import os, json, requests, time
HOST = 'https://your-host/index.php'
def rpc(method, params):
body = { 'jsonrpc': '2.0', 'id': str(int(time.time()*1000)), 'method': method, 'params': params }
r = requests.post(HOST, headers={
'Content-Type': 'application/json',
}, data=json.dumps(body))
j = r.json()
if 'error' in j:
raise RuntimeError(f"{j['error']['code']}: {j['error']['message']}")
return j['result']
init = rpc('initialize', {})
tools = rpc('tools/list', {})
print('Tools:', [t['name'] for t in tools['tools']])
contacts = rpc('tools/call', {
'name': 'vbout_get_contacts',
'arguments': { 'api_key': os.environ['VBOUT_KEY'], 'limit': 10 }
})
print('Contacts result:', contacts['content'][0])
gh = rpc('tools/call', {
'name': 'github_repo_info',
'arguments': { 'token': os.environ['GITHUB_TOKEN'], 'owner': 'octocat', 'repo': 'Hello-World' }
})
print('GitHub result:', gh['content'][0])
Best Practices
- Pass service credentials per call (
api_key
,token
) inarguments
instead of storing on the server. - Do not store service API keys on the server. VBOUT tools require
arguments.api_key
and will returnMissing api_key
if omitted. No config-based fallback is used. - Respect rate limits; 60 requests/min default, adjustable in
public/index.php
. - Validate tool inputs according to each tool’s
inputSchema
fromtools/list
.# php-mcp-server