A minimal, zero-dependency Model Context Protocol (MCP) server written in PHP ≥ 8.4. It speaks JSON-RPC 2.0 and exposes tools to MCP clients such as Claude Desktop, Cherry Studio, or Open WebUI. In HTTP mode it also ships a self-hosted OAuth 2.1 Authorization Server (Authorization Code + PKCE, dynamic client registration, and discovery metadata) so clients can log in with real user accounts.
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}\n' | php index.php- MCP protocol —
initialize,tools/list,tools/call,notifications/initialized(protocol version2024-11-05). - Three transports from a single entry point:
- stdio — line-delimited JSON-RPC over stdin/stdout, how MCP clients launch the server.
- Streamable HTTP — direct JSON-RPC POST (
/or/mcp) with optionalMcp-Session-Idhandling. - SSE MCP (Server-Sent Events) — standard MCP SSE stream (
GET /sse) with message receiver (POST /message?sessionId=...).
- File-per-Page (PRG pattern) — each web page is a dedicated file that strictly follows the Post/Redirect/Get pattern with flash messages to prevent form re-submissions.
- Standalone File-per-API — every API endpoint (OAuth token, client registration, passkey options/verify, discovery metadata, MCP JSON-RPC, and SSE) lives in its own standalone script.
- Attribute-based tools — drop a class in src/Tools/ with
#[McpFunction]methods and it is auto-discovered; no manual registration. - Per-tool access control — tools declare required
roles/permissions; unauthorized tools are hidden fromtools/listand rejected attools/callwith JSON-RPC-32001. - User context — the current
UserContextis injected into any tool method that asks for it. In stdio mode every request runs as a trustedlocaluser with full access. - Self-hosted OAuth 2.1 — interactive login page, token endpoint, RFC 7591 dynamic client registration, RFC 8414 / RFC 9728 discovery. Tokens are sha256-hashed at rest.
- WebAuthn Passkeys — passwordless biometric and hardware security key logins (FIDO2 / WebAuthn).
- Email Two-Factor Authentication (2FA) — 6-digit OTP verification for new devices, with trusted device cookies (90-day persistence) and zero external dependencies. Copy config/mail.example.php to
config/mail.phpto configure SMTP or log driver. - User management page (
/account) — login, public onboarding, change password, passkey registration, email management, trusted devices, and logout. - SQLite storage — users, OAuth clients, passkeys, trusted devices, and tokens in
data/app.sqlite(gitignored), seeded from config/users.php on first run. - No dependencies —
composer.jsondeclares onlyphp >= 8.4.
- PHP ≥ 8.4 (uses
json_validate(),match, readonly classes, and union types).
MCP clients launch the dedicated stdio script directly:
{
"mcpServers": {
"simplemcp": {
"command": "php",
"args": ["D:\\Project\\SimpleMCP\\stdio.php"]
}
}
}Smoke test:
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}\n' | php stdio.phpFor local development:
php -S localhost:8000 -t public/For production/public web servers (Nginx/Apache/Caddy):
- Point your web server document root to the
public/directory. - This isolates
data/(SQLite databases, logs, sessions) andconfig/completely outside the web root. - All HTTP responses carry security headers (
X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin). - Cookies are hardened (
HttpOnly,SameSite=Lax, andSecureon HTTPS). - Configure a canonical
issuerinconfig/oauth.phpto prevent Host-header poisoning.
All tools on this branch are examples that demonstrate the server's capabilities:
| Tool | Description | Access |
|---|---|---|
add_numbers |
Addition of two numbers | public |
subtract_numbers |
Subtraction of two numbers | public |
multiply_numbers |
Multiplication of two numbers | public |
divide_numbers |
Division of two numbers (rounded to 2 dp) | public |
power_numbers |
Base raised to an exponent | public |
calculate_loan_installment |
Fixed monthly loan payment (PMT formula), with total paid and interest | public |
get_system_time |
Current server time (ISO 8601) | public |
get_current_user |
The authenticated caller (username, name, roles, permissions) | public |
server_status |
Server runtime info | admin (roles: ['admin']) |
Tools live in src/Tools/. Create a class whose methods are decorated with
#[McpFunction(name, description, schema)]. Each method receives the raw arguments array
and returns an array of MCP content blocks.
<?php
declare(strict_types=1);
namespace McpServer\Tools;
use McpServer\Attributes\McpFunction;
readonly class GreetingTool {
#[McpFunction(
name: 'greet',
description: 'Greets a person by name.',
schema: [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string', 'description' => 'The name to greet'],
],
'required' => ['name'],
],
)]
public function greet(array $arguments): array {
$name = $arguments['name'] ?? 'world';
return [['type' => 'text', 'text' => "Hello, $name!"]];
}
}src/McpServer.php calls registerToolsFromDirectory(), which
auto-discovers every non-abstract class in src/Tools/ containing at least one
#[McpFunction] method — no manual registration step.
Pass roles and/or permissions to the attribute (see AdminTool.php):
#[McpFunction(
name: 'server_status',
description: 'Returns server runtime information. Admin-only.',
schema: ['type' => 'object', 'properties' => new \stdClass()],
roles: ['admin'],
)]Access rule: a tool that declares a category requires the caller to match within every
declared category (any match within a category suffices). A tool with no roles /
permissions is public — anonymous HTTP callers can list and call it.
Declare an optional ?UserContext $user = null parameter (explicit ? — implicit-nullable
is deprecated in PHP 8.4). The server detects it via reflection at registration and injects
the context at call time; the arguments array stays at parameter index 0
(UserInfoTool.php):
public function getCurrentUser(array $arguments, ?UserContext $user = null): array {
$user ??= UserContext::anonymous();
return [['type' => 'text', 'text' => json_encode($user->toArray())]];
}Users are seeded from config/users.php into SQLite only when the users
table is empty. On this branch the seed is:
| Username | Password | Roles | Status |
|---|---|---|---|
admin |
admin123 |
admin |
active |
alice |
secret |
user |
active |
bob |
(none yet) | user |
pending |
These are dev-only seed credentials — change them before deploying anywhere real.
A user added with no password (status pending) must complete onboarding: the first time
they log in — via /account or the OAuth flow a tool call triggers — they are asked to set a
password. Their provisioned username is kept and is not editable. /account/onboard is the
public path for brand-new users, who choose their own username. New accounts are granted
no special roles ([]); grant admin (or other roles) by editing config/users.php or the
users table.
In stdio mode there is no HTTP layer: every request runs as the trusted local user with
the * wildcard role/permission, so all tools are visible and callable.
When served over HTTP, requests are optionally authenticated:
- No bearer token → the caller is
UserContext::anonymous(); only tools with noroles/permissionsare listed and callable. - Present but invalid/expired token →
401with aWWW-Authenticatechallenge pointing at the protected-resource metadata, so MCP clients re-authenticate. - Valid token → resolved to the matching account.
The flow is Authorization Code + PKCE, hosted by the server itself:
| Endpoint | Purpose |
|---|---|
GET/POST /oauth/authorize |
Validates the client/redirect (exact match, https except loopback), renders the two-step login (username, then password or onboarding), redirects with a one-time code. Posting anonymous=1 skips login and issues a code for the anonymous identity. |
POST /oauth/token |
Exchanges code + code_verifier for {access_token, refresh_token}; rotates refresh tokens. Confidential clients authenticate via client_secret_post (form body) or client_secret_basic (HTTP Basic) and may skip PKCE; public clients must use PKCE (S256). |
POST /oauth/register |
RFC 7591 dynamic client registration — post redirect_uris, token_endpoint_auth_method, etc. and get back a client_id (+ client_secret when confidential). Optionally protected by registration_access_token in config. |
GET /.well-known/oauth-authorization-server |
RFC 8414 discovery metadata. |
GET /.well-known/oauth-protected-resource |
RFC 9728 protected-resource metadata. |
Only none and client_secret_post are advertised in discovery — Cherry Studio fails the
OAuth handshake when client_secret_basic is offered, so DCR-registered clients default to
client_secret_post.
- Claude Desktop / Cherry Studio / generic — point them at
php index.php(stdio) or the HTTP endpoint, and let them complete the interactive browser login at/oauth/authorize. - Open WebUI — use the pre-registered confidential client
openwebuifrom config/oauth.php and pick OAuth 2.1 (Static) when adding the MCP server, pasting the client id + secret. The interactive login is the same/oauth/authorizepage; its redirect URI must be inopenwebui'sredirect_uris. - Anonymous — POST
anonymous=1to/oauth/authorize(with a valid client/redirect/PKCE) and exchange the resulting code normally; the client connects but only sees/calls public tools.
Users can log in passwordlessly with biometrics (Touch ID, Face ID, Windows Hello) or physical security keys (YubiKey):
- Registration: Log into
/accountand click "Add Passkey". The browser triggersnavigator.credentials.create()and saves the public key in SQLite. - Login: Both
/account/loginand/oauth/authorizesupport "Sign in with Passkey" and browser WebAuthn Conditional UI (autofill). - OAuth 2.1 Compatibility: When an MCP client triggers the OAuth flow, the user authenticates with their passkey in the browser, and the server automatically issues the authorization code back to the MCP client.
SimpleMCP supports Dynamic Client Registration (RFC 7591) while keeping the database clean via a CLI housekeeping tool:
# View dynamically registered clients and expired token stats
php cli/cleanup_clients.php
# Prune expired tokens and inactive dynamic clients (older than 30 days)
php cli/cleanup_clients.php --prune
# Custom threshold (e.g. 7 days) or dry run
php cli/cleanup_clients.php --prune --days=7 --dry-run- config/users.php — seed users.
passwordis a bcrypt hash;status: 'pending'marks a user for onboarding. - config/oauth.php — OAuth clients, token/code TTLs,
registration_access_token, and canonicalissuerfor public deployment.
stdio.php dedicated stdio JSON-RPC loop for MCP clients
public/
index.php front controller / clean URL router
bootstrap.php shared bootstrap entry point
layout.php shared modern UI layout with flash message support
mcp.php standalone Streamable HTTP MCP JSON-RPC API
sse.php standalone Server-Sent Events (SSE) MCP stream
message.php standalone MCP SSE message receiver API
.well-known/
oauth-authorization-server.php RFC 8414 discovery metadata API
oauth-protected-resource.php RFC 9728 discovery metadata API
oauth/
authorize.php OAuth 2.1 authorization consent & login page (PRG)
token.php OAuth 2.1 token endpoint API
register.php RFC 7591 dynamic client registration API
passkey/
options.php OAuth passkey login options API
verify.php OAuth passkey login verify API
account/
index.php account dashboard page (PRG)
login.php account login page (PRG)
logout.php account logout action (PRG)
onboard.php account onboarding page (PRG)
change-password.php change password action (PRG)
update-email.php update email action (PRG)
2fa/
verify.php 2FA verification code page (PRG)
resend.php 2FA code resend action (PRG)
set-email.php 2FA email setup page (PRG)
device/
revoke.php revoke trusted device action (PRG)
passkey/
delete.php delete passkey action (PRG)
register/
options.php passkey registration options API
verify.php passkey registration verify API
login/
options.php passkey login options API
verify.php passkey login verify API
cli/
cleanup_clients.php housekeeping CLI for dynamic clients and expired tokens
index.php root delegator (CLI -> stdio.php, HTTP -> public/index.php)
config/
users.php seed users
oauth.php OAuth clients, TTLs, issuer, registration token
src/
bootstrap.php core bootstrap, container, and security/session helpers
McpServer.php MCP core: tool registry, routing, access control, UserContext injection
UserContext.php immutable user value object (local() / anonymous() factories)
Attributes/McpFunction.php the #[McpFunction] attribute
Tools/ auto-discovered tool classes
Auth/
Database.php SQLite bootstrap + schema (users, clients, tokens, passkeys)
UserStore.php DB-backed accounts: auth, onboarding, change password
TokenStore.php OAuth codes/access/refresh tokens (sha256-hashed, single-use, rotating)
ClientStore.php OAuth client registry: static config + RFC 7591 dynamic clients
PasskeyStore.php WebAuthn passkey credential persistence & counter tracking
WebAuthn.php pure-PHP WebAuthn engine (ES256, RS256, CBOR decoding)
TwoFactorService.php email 2FA service and trusted device tracking
EmailService.php email delivery engine (SMTP, log driver)
DebugLog.php append-only diagnostics log
data/ runtime-only, gitignored (app.sqlite, requests.log, sessions/)
data/is runtime-only and gitignored (/data/*):app.sqlite(users + tokens),requests.log,sessions/. Deleting it re-seeds users fromconfig/users.phpon the next HTTP start.- Run the dev server with
php -S localhost:8000 index.phpsodata/is never served statically. - The
try/catchinprocessMethod()catchesThrowable, so PHPErrors escaping a tool become a-32603response instead of killing the process. - A
tools/callresponse always setsisError: false; tools that return error text in a content block are still reported as successful. Access denials are a JSON-RPC-32001error, not a tool result. - Tool methods receive the unvalidated
argumentsarray — defaults and validation are the tool's responsibility (seeCalculatorTool::calculateLoanInstallment). - HTTP requests are optionally authenticated: no token → anonymous; present-but-invalid token
→ 401. To test the full authenticated flow, complete the OAuth login in a browser (or with
curl), or log in at
/account. - The HTTP server is stateless about MCP sessions: it mints an
Mcp-Session-Idoninitialize(needed by some clients, e.g. Cherry Studio) and echoes it back, but stores nothing. vendor/contains unused leftovers (phpdotenv, extendorm) not declared incomposer.json.
This project is licensed under the MIT License.