Skip to content

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:

{ "data": { ... } }

On error:

{ "error": { "code": "string", "message": "string" } }

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 no Bearer header 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:

  1. Read-only mode. When the key was created with read_only: true, any method other than GET is refused before the call leaves the MCP layer.
  2. Scopes. When the key carries a non-empty scopes list, the target path is mapped to a scope category by path prefix (unmapped paths fall under administrative_operations) and the call is refused unless the key holds that category. An empty scope list means "inherit all of the owner's permissions".
  3. 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 real is_admin / superuser flags. The panel's normal AuthWithDB → RequirePermission → handler pipeline 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: method and path (required; the /api prefix is added automatically), plus an optional body object.
  • list_endpoints - discovery. Optional query argument filters the panel's live route table by case-insensitive substring; returns a plain-text METHOD path listing. 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_used on every successfully authenticated request.
  • tools/call issues 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), and X-Replication-Source: mcp set 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

curl https://panel.example.com:2087/api/mcp-keys \
  -H "Authorization: Bearer $TOKEN"

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_keys with enabled = 1 and the SHA-256 hash of the token. The plaintext is never persisted.
  • Writes an mcp_key_created entry 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

{ "data": { "success": true } }

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_deleted entry to the activity log, recording the acting superuser, client IP, and id=<id>.

Example

curl -X DELETE https://panel.example.com:2087/api/mcp-keys/2 \
  -H "Authorization: Bearer $TOKEN"

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

{
  "enabled": "bool (required)"
}
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

{ "data": { "success": true } }

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 enabled column for the matching row in mcp_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

curl https://panel.example.com:2087/api/mcp-keys/scopes \
  -H "Authorization: Bearer $TOKEN"

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

curl https://panel.example.com:2087/api/mcp-keys/tls \
  -H "Authorization: Bearer $TOKEN"

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".

-----BEGIN CERTIFICATE-----
MIID...
-----END CERTIFICATE-----

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.

{
  "hosts": "[]string (optional)",
  "restart": "bool (optional)"
}
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 (mode 0755) 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 with ServerAuth extended 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 localhost and the machine hostname, plus anything supplied in hosts. IPs and names are de-duplicated.
  • Backs up the existing pair as /etc/starkone/ssl/panel.crt.bak and /etc/starkone/ssl/panel.key.bak, preserving the original file modes, then writes panel.crt (0644) and panel.key (0600).
  • Writes a panel_tls_regenerated entry to the activity log with the new certificate's IP addresses, comma-separated.
  • Unless restart: false was sent, schedules systemctl restart starkone in 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

curl -X POST https://panel.example.com:2087/api/mcp-keys/tls/regenerate \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"hosts": ["panel.example.com", "203.0.113.10"], "restart": true}'