MCP Server¶
Embedded Model Context Protocol server: per-user scoped access keys, the JSON-RPC protocol endpoint, and the panel's own TLS certificate.
All endpoints below are mounted under /api. Authentication: Authorization: Bearer <token> unless noted as public.
Endpoint count: 9
Endpoints¶
| Method | Path | Handler | Guards |
|---|---|---|---|
ANY |
/api/mcp |
mcpEndpoint |
RequirePlugin(db, "mcp") |
GET |
/api/mcp-keys |
listMCPKeys |
RequireSuperuser(), RequirePlugin(db, "mcp") |
POST |
/api/mcp-keys |
createMCPKey |
RequireSuperuser(), RequirePlugin(db, "mcp") |
DELETE |
/api/mcp-keys/:id |
deleteMCPKey |
RequireSuperuser(), RequirePlugin(db, "mcp") |
POST |
/api/mcp-keys/:id/toggle |
toggleMCPKey |
RequireSuperuser(), RequirePlugin(db, "mcp") |
GET |
/api/mcp-keys/scopes |
listMCPScopes |
RequireSuperuser(), RequirePlugin(db, "mcp") |
GET |
/api/mcp-keys/tls |
getPanelTLS |
RequireSuperuser(), RequirePlugin(db, "mcp") |
GET |
/api/mcp-keys/tls/download |
downloadPanelTLS |
RequireSuperuser(), RequirePlugin(db, "mcp") |
POST |
/api/mcp-keys/tls/regenerate |
regeneratePanelTLS |
RequireSuperuser(), RequirePlugin(db, "mcp") |
Source¶
Handlers for this section live in api/routes/handlers_*.go in the StarkOne source. Each handler binds its request body with gin.ShouldBindJSON(&req) and responds with the standard envelope:
On error:
Reference¶
ANY /api/mcp¶
The MCP protocol endpoint: JSON-RPC 2.0 over the MCP Streamable HTTP transport. Registered with api.Any(...), so every HTTP method reaches the handler, but only POST is served - GET and everything else return 405.
Guards: RequirePlugin(db, "mcp")
Authentication
This endpoint is registered on the bare api group, not on authGroup - it has no Bearer/JWT middleware, so the standard panel token does not apply here. It authenticates with its own MCP keys instead:
Authorization: Bearer mcp_...(preferred), or?token=mcp_...as a query parameter (fallback, used only when noBearerheader is present).
The token is SHA-256 hashed and matched against mcp_keys.key_hash. The key must exist, be enabled, and not be expired. On success mcp_keys.last_used is bumped. A token that does not begin with mcp_ is rejected outright.
Security model
What a key may do is narrowed by three layers, applied in order:
- Read-only mode. When the key was created with
read_only: true, any method other thanGETis refused before the call leaves the MCP layer. - Scopes. When the key carries a non-empty
scopeslist, the target path is mapped to a scope category by path prefix (unmapped paths fall underadministrative_operations) and the call is refused unless the key holds that category. An empty scope list means "inherit all of the owner's permissions". - The owning user's RBAC - always the ceiling. A tool call is not executed in-process. The MCP server issues a real HTTP request to the panel's own REST API over loopback (
https://127.0.0.1:<panel port>/api<path>), authenticated with a short-lived JWT (2 minute expiry) minted for the key's owner and carrying that user's realis_admin/superuserflags. The panel's normalAuthWithDB → RequirePermission → handlerpipeline therefore authorizes the call exactly as it would for that user's browser session.
The MCP layer can only narrow the owner's authority; it can never grant more than the user already has. Denials from layers 1 and 2 come back as a tool result with isError: true; denials from layer 3 come back as the panel's own 403, rendered as HTTP 403 in the tool text.
Request body
A single JSON-RPC 2.0 request object. Request bodies are capped at 8 MiB.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "server_info", "arguments": {} }
}
| Field | Type | Required | Description |
|---|---|---|---|
jsonrpc |
string | no | Protocol marker. Accepted but not validated; responses always carry "2.0". |
id |
any JSON value | no | Request id, echoed back. Omitted or null makes the message a notification. |
method |
string | yes | One of the supported methods below. |
params |
object | no | Method parameters. |
Supported methods
| Method | Result |
|---|---|
initialize |
Handshake. Echoes the client's protocolVersion when supplied, otherwise advertises 2025-06-18. Returns capabilities.tools, serverInfo (starkone-panel, version 1.0.0), and an instructions string naming the key's owner and its effective scope. |
ping |
Empty object result. |
tools/list |
{ "tools": [ ... ] } - the full tool list. |
tools/call |
Executes one tool and returns MCP text content. |
notifications/initialized |
Empty object result. As a notification it produces 202 Accepted with no body. |
notifications/cancelled |
Empty object result. As a notification it produces 202 Accepted with no body. |
Session state is not tracked: no Mcp-Session-Id is issued or required, which the Streamable HTTP spec permits. Every request carries its own mcp_ credential.
Tools
tools/list returns 17 tools: 15 curated convenience tools sorted by name, followed by the two generic ones.
- Curated:
activity_log,firewall_status,get_dns_zone,get_website,list_plugins,list_services,list_users,list_website_backups,list_website_crontab,list_website_databases,list_websites,server_info,service_action,system_updates,website_ssl_status. Each resolves to a fixed method + path against the panel REST API. api_call- calls any panel REST endpoint. Arguments:methodandpath(required; the/apiprefix is added automatically), plus an optionalbodyobject.list_endpoints- discovery. Optionalqueryargument filters the panel's live route table by case-insensitive substring; returns a plain-textMETHOD pathlisting. This tool makes no API call.
Response - 200 OK
Every JSON-RPC response - including JSON-RPC-level errors - is written with HTTP 200. Successful tools/call:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{ "type": "text", "text": "HTTP 200\n{\n \"data\": { \"hostname\": \"panel.example.com\" }\n}" }
],
"isError": false
}
}
Tool text is the string HTTP <status> followed by the panel's response body, pretty-printed when it is valid JSON. isError is true when the underlying call returned status >= 400, when a scope or read-only gate denied the call, or when required tool arguments were missing.
initialize:
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": { "tools": {} },
"serverInfo": { "name": "starkone-panel", "version": "1.0.0" },
"instructions": "StarkOne server-management panel for user \"ops-user\" (scope: read-only). Use list_endpoints to discover the full REST API and api_call to invoke any endpoint."
}
}
A JSON-RPC error result:
{
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32601, "message": "method not found: resources/list" }
}
Errors
HTTP-level responses:
| Status | Code | Meaning |
|---|---|---|
401 |
unauthorized |
No token supplied, the token does not start with mcp_, or it is invalid, disabled, or expired. Returned in the panel's standard { "error": { "code", "message" } } envelope, not as JSON-RPC. |
405 |
- | GET (plain-text body MCP endpoint accepts POST) or any other non-POST method. Allow: POST is set. |
202 |
- | A notification (no id, or id: null) that dispatched without error. Empty body. |
JSON-RPC error codes, all carried inside a 200 response:
| Code | Meaning |
|---|---|
-32700 |
Parse error - the body could not be decoded as a JSON-RPC request. id is null. |
-32601 |
Method not found - method is not one of the six supported methods. |
-32602 |
Invalid params - tools/call params could not be unmarshalled, name was empty, or the named tool does not exist. |
Failures inside a tool (denied by read-only/scope, transport failure, or an API status >= 400) are not JSON-RPC errors: they come back as a normal result with isError: true.
Side effects
- Updates
mcp_keys.last_usedon every successfully authenticated request. tools/callissues a real HTTP request to the panel's own API over loopback with a 90 second client timeout, TLS verification skipped (self-signed panel certificate), andX-Replication-Source: mcpset so writes are not re-mirrored. Response bodies are read up to 4 MiB.- Any side effect of the invoked panel endpoint applies as normal, subject to the owner's RBAC.
Example
curl -X POST https://panel.example.com:2087/api/mcp \
-H "Authorization: Bearer mcp_a1b2c3..." \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "api_call", "arguments": {"method": "GET", "path": "/server/info"}}}'
Listing the available tools:
curl -X POST https://panel.example.com:2087/api/mcp \
-H "Authorization: Bearer mcp_a1b2c3..." \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}'
GET /api/mcp-keys¶
Lists every MCP key on the panel (superuser view - mcp.ListKeys is called with user id 0, which returns all owners), newest first. Key hashes and plaintext tokens are never returned; only the 12-character key_prefix is.
Guards: RequireSuperuser(), RequirePlugin(db, "mcp")
Response - 200 OK
{
"data": [
{
"id": 1,
"user_id": 4,
"username": "ops-user",
"name": "laptop-client",
"key_prefix": "mcp_a1b2c3d4",
"scopes": ["websites_management"],
"read_only": true,
"enabled": true,
"expires_at": "2026-12-31T00:00:00Z",
"created_at": "2026-06-17T12:00:00Z",
"last_used": "2026-06-18T09:30:00Z"
}
]
}
expires_at and last_used are omitted when null. scopes is always an array - an empty array means the key inherits all of its owner's permissions.
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
list_failed |
The query against mcp_keys (joined to users) failed. |
Example
POST /api/mcp-keys¶
Mints a new MCP key bound to an existing panel user. The plaintext token is mcp_ followed by 64 hex characters (32 random bytes) and is returned exactly once, in this response - only its SHA-256 hash is stored, so it cannot be recovered later.
Guards: RequireSuperuser(), RequirePlugin(db, "mcp")
Request body
{
"user_id": "int (required)",
"name": "string (optional)",
"scopes": "[]string (optional)",
"read_only": "bool (optional)",
"expires_in_days": "int (optional)"
}
| Field | Type | Required | Description |
|---|---|---|---|
user_id |
int | yes | Id of the panel user the key is bound to. Must be > 0 and must exist in users. The key's authority is that user's RBAC, permanently. |
name |
string | no | Display label for the key. Defaults to mcp-key when empty. |
scopes |
array of string | no | Scope categories to restrict the key to. Unrecognized values are silently dropped by filterScopes, so an all-invalid list becomes an empty list. An empty list means "inherit all of the owner's permissions". Valid values come from GET /api/mcp-keys/scopes. |
read_only |
bool | no | When true the key may only issue GET requests; every other method is refused before the call reaches the API. |
expires_in_days |
int | no | Days until expiry, counted from now. Values <= 0 (including omission) create a key that never expires. |
Response - 200 OK
{
"data": {
"key": {
"id": 2,
"user_id": 4,
"username": "ops-user",
"name": "laptop-client",
"key_prefix": "mcp_a1b2c3d4",
"scopes": ["websites_management"],
"read_only": true,
"enabled": true,
"expires_at": "2026-12-31T00:00:00Z",
"created_at": "2026-06-17T12:00:00Z"
},
"token": "mcp_a1b2c3..."
}
}
token is the full plaintext key (mcp_ + 64 hex characters, truncated above). It is shown here and never again - store it at once. key.key_prefix is the first 12 characters of the token (mcp_ plus the first 8 hex characters) and is what appears in listings.
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
bad_request |
Request body could not be parsed as JSON (invalid body), or user_id was missing or <= 0 (user_id is required). |
404 |
not_found |
No user with the given user_id exists. |
500 |
create_failed |
Token generation or the insert into mcp_keys failed. |
Side effects
- Inserts a row into
mcp_keyswithenabled = 1and the SHA-256 hash of the token. The plaintext is never persisted. - Writes an
mcp_key_createdentry to the activity log, recording the acting superuser, client IP, and<key name> for <username>.
Example
curl -X POST https://panel.example.com:2087/api/mcp-keys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id": 4, "name": "laptop-client", "scopes": ["websites_management"], "read_only": true, "expires_in_days": 90}'
DELETE /api/mcp-keys/:id¶
Permanently deletes an MCP key by id, immediately revoking any client still holding its token. Because the caller is a superuser, the delete is not scoped to an owner - any key may be removed.
Guards: RequireSuperuser(), RequirePlugin(db, "mcp")
Path parameters
| Name | Type | Description |
|---|---|---|
id |
int | The id of the key to delete. Must parse as an integer. |
Response - 200 OK
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
bad_request |
id is not an integer (invalid id). |
404 |
not_found |
No key with that id exists (message key not found), or the delete failed. |
Side effects
- Deletes the matching row from
mcp_keys. - Writes an
mcp_key_deletedentry to the activity log, recording the acting superuser, client IP, andid=<id>.
Example
POST /api/mcp-keys/:id/toggle¶
Enables or disables an MCP key without deleting it. Despite the name, this sets the enabled flag to the value in the request body rather than flipping the current one. A disabled key fails authentication at /api/mcp with key disabled.
Guards: RequireSuperuser(), RequirePlugin(db, "mcp")
Path parameters
| Name | Type | Description |
|---|---|---|
id |
int | The id of the key to enable or disable. Must parse as an integer. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
enabled |
bool | yes | Target state. A valid JSON body is required; an omitted enabled field defaults to false and disables the key. |
Response - 200 OK
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
bad_request |
id is not an integer (invalid id), or the body could not be parsed as JSON (invalid body). |
404 |
not_found |
No key with that id exists (message key not found), or the update failed. |
Side effects
- Updates the
enabledcolumn for the matching row inmcp_keys. Nothing is written to the activity log.
Example
curl -X POST https://panel.example.com:2087/api/mcp-keys/2/toggle \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled": false}'
GET /api/mcp-keys/scopes¶
Returns the canonical list of scope categories a key may be restricted to. These mirror the panel's own permission slugs so the UI can render a familiar checklist, and they are the only values POST /api/mcp-keys accepts in scopes.
Guards: RequireSuperuser(), RequirePlugin(db, "mcp")
Response - 200 OK
{
"data": [
"websites_management",
"websites_administration",
"administrative_operations",
"firewall_management",
"booting_users_management"
]
}
A path is mapped to a category by prefix: /firewall → firewall_management; /users → booting_users_management; /webapps, /websites, /databases, /dns, /ssl, /cron, /ftp, /backups, /mail, /aliases, /performance, /varnish, /git → websites_management. Any unmapped path falls under administrative_operations.
Errors
This endpoint does not return error responses. The list is a compile-time constant (mcp.ScopeCategories).
Example
GET /api/mcp-keys/tls¶
Describes the panel's installed HTTPS certificate at /etc/starkone/ssl/panel.crt and lists the server's own reachable IP addresses. This lives on the MCP screen because an MCP client cannot connect to a panel whose certificate it cannot validate.
Guards: RequireSuperuser(), RequirePlugin(db, "mcp")
Response - 200 OK
{
"data": {
"certificate": {
"exists": true,
"subject": "CN=StarkOne Panel,O=StarkOne",
"issuer": "CN=StarkOne Panel,O=StarkOne",
"self_signed": true,
"not_before": "2026-06-17T11:00:00Z",
"not_after": "2036-06-17T12:00:00Z",
"expired": false,
"days_left": 3650,
"ip_addresses": ["127.0.0.1", "::1", "203.0.113.10"],
"dns_names": ["localhost", "panel.example.com"],
"has_san": true,
"missing_ips": [],
"needs_refresh": false,
"reason": ""
},
"server_ips": ["127.0.0.1", "::1", "203.0.113.10"]
}
}
| Field | Type | Description |
|---|---|---|
certificate.exists |
bool | False when no certificate is installed, the file is not valid PEM, or it cannot be parsed. In those cases the remaining fields stay at their zero values and reason explains why. |
certificate.self_signed |
bool | True when subject and issuer strings match. |
certificate.expired |
bool | True when not_after is in the past. |
certificate.days_left |
int | Whole days until not_after. |
certificate.ip_addresses |
array of string | IP entries in the Subject Alternative Name extension. |
certificate.dns_names |
array of string | DNS entries in the SAN extension. |
certificate.has_san |
bool | True when the certificate carries any IP or DNS SAN entry. |
certificate.missing_ips |
array of string | This server's addresses that the certificate does not cover, sorted. |
certificate.needs_refresh |
bool | True when the certificate is expired, has no SAN, misses some of the server's addresses, or expires in under 30 days. |
certificate.reason |
string | Human-readable explanation for needs_refresh; omitted when empty. |
server_ips |
array of string | The server's own IPv4/IPv6 addresses including loopback, excluding link-local addresses - the addresses a client could legitimately use to reach the panel. |
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
read_failed |
Inspecting the certificate directory failed. A missing or unparseable certificate is not an error: it returns 200 with exists: false and needs_refresh: true. |
Example
GET /api/mcp-keys/tls/download¶
Serves the panel's public certificate (/etc/starkone/ssl/panel.crt) so an operator can pin it locally - for example via NODE_EXTRA_CA_CERTS - and keep TLS verification switched on in an MCP client instead of disabling it. The private key is never served.
Guards: RequireSuperuser(), RequirePlugin(db, "mcp")
Response - 200 OK
This endpoint returns a file, not JSON. The body is the raw PEM certificate, sent with Content-Disposition: attachment; filename="starkone-panel.crt".
Errors
| Status | Code | Meaning |
|---|---|---|
404 |
not_found |
No certificate file is installed at /etc/starkone/ssl/panel.crt (message no certificate installed). This error is returned as JSON. |
Example
curl -OJ https://panel.example.com:2087/api/mcp-keys/tls/download \
-H "Authorization: Bearer $TOKEN"
POST /api/mcp-keys/tls/regenerate¶
Issues a fresh self-signed certificate for the panel covering the server's own addresses in the Subject Alternative Name extension, then restarts the panel so it is picked up. This is the fix for a panel reached by IP whose certificate clients refuse to verify.
Guards: RequireSuperuser(), RequirePlugin(db, "mcp")
Request body
The body is optional - a bind failure is ignored, so an absent, empty, or malformed body is treated as all-defaults.
| Field | Type | Required | Description |
|---|---|---|---|
hosts |
array of string | no | Extra hosts to add to the SAN. Each entry that parses as an IP address is added as an IP SAN; anything else is added as a DNS SAN. Empty strings are skipped. These are added on top of the server's own addresses, which are always included. |
restart |
bool | no | Whether to restart the panel service afterwards. Defaults to true - the restart is skipped only when false is explicitly sent. |
Response - 200 OK
{
"data": {
"certificate": {
"exists": true,
"subject": "CN=StarkOne Panel,O=StarkOne",
"issuer": "CN=StarkOne Panel,O=StarkOne",
"self_signed": true,
"not_before": "2026-06-17T11:00:00Z",
"not_after": "2036-06-17T12:00:00Z",
"expired": false,
"days_left": 3650,
"ip_addresses": ["127.0.0.1", "::1", "203.0.113.10"],
"dns_names": ["localhost", "panel-host", "panel.example.com"],
"has_san": true,
"missing_ips": [],
"needs_refresh": false
},
"restarting": true
}
}
certificate has the same shape as GET /api/mcp-keys/tls, re-read from disk after writing. restarting reports whether a panel restart was scheduled.
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
generate_failed |
Creating the certificate directory, generating the RSA key or serial, creating the certificate, or writing panel.crt / panel.key failed. |
Side effects
- Creates
/etc/starkone/ssl(mode0755) if absent. - Generates a 2048-bit RSA key and a self-signed certificate valid for 10 years (backdated 1 hour), subject
CN=StarkOne Panel, O=StarkOne, marked as a CA withServerAuthextended key usage so clients can trust it directly. - SAN covers every one of the server's non-link-local IPv4/IPv6 addresses (loopback included), the DNS names
localhostand the machine hostname, plus anything supplied inhosts. IPs and names are de-duplicated. - Backs up the existing pair as
/etc/starkone/ssl/panel.crt.bakand/etc/starkone/ssl/panel.key.bak, preserving the original file modes, then writespanel.crt(0644) andpanel.key(0600). - Writes a
panel_tls_regeneratedentry to the activity log with the new certificate's IP addresses, comma-separated. - Unless
restart: falsewas sent, schedulessystemctl restart starkonein the background after roughly 2 seconds - long enough for this response to reach the client. The panel connection will drop shortly after this call returns.
Example