GitHub
09/04/2026, 10:02 AM/api/v1/mcp
Adds a hosted transport for the MCP server built in the previous PR, so a team
shares one endpoint instead of each operator installing osctrl-mcp. Same nine
read-only tools; each caller acts as themselves.
Off by default. Enable with the mcp: section in api.yml, or
--mcp-enabled / MCP_ENABLED=true. When disabled the route is not registered
at all, so a deployment never inherits the surface on upgrade.
mcp:
enabled: false
Enabling it grants no new access
Requests authenticate with the same bearer token or session cookie as any other
API call. Each tool call is then dispatched back through osctrl-api's own
handlers as the calling user, so the per-endpoint permission checks run
exactly as they do for the SPA or osctrl-cli.
That indirection is the point, and it came out of a finding while building this:
osctrl's read permissions are not uniform. get_node requires
AdminLevel, node posture only UserLevel, queries QueryLevel, the osquery
schema none at all. An in-process backend reading the managers directly would
have meant restating that policy across nine call sites — and picking
UserLevel for reading a node, which is the obvious guess, would have silently
over-granted.
So the MCP layer contains no authorization logic. loopbackTransport
(cmd/api/mcp.go) is an http.RoundTripper that dispatches into the API mux
instead of onto the network: real handler chain, real permission checks, real
audit logging, no socket and no loopback network hop. getServer builds a
client per request bound to that request's credentials, so sessions can never
share an identity.
This needed no change to pkg/mcp — *apiclient.OsctrlAPI satisfies Backend
verbatim, just with a different transport. The only client addition is
CreateAPIWithTransport.
Configuration
New mcp: section plumbed through APIConfiguration, ServiceParameters,
loadedYAMLToServiceParams, and GenerateAPIConfigFile — all four, since a
section missed in any one of them is silently dropped.
ConfigVersion bumped to 2 with both sample files updated, per the policy in
pkg/config/version.go that any added field bumps the schema.
Behind the bundled nginx config this works as-is: /api/ already proxies with
proxy_buffering off, which the streaming transport needs.
Tests
Four in cmd/api/mcp_test.go, driving a real MCP client over HTTP against a
recording handler:
• credentials, RemoteAddr, and User-Agent reach the handler — audit entries
attribute to the real caller, not to the server talking to itself
• a 403 from the handler chain surfaces as a tool error, never as
empty-but-successful data — the property the whole design rests on
• the secret-stripping environment projection still holds over this transport
• two callers in sequence don't share an identity
Plus two config round-trip tests, mirroring the ones that caught the dropped
SAML/OIDC sections earlier.
go build ./... and go vet ./... clean; all 49 packages pass. Verified
config-generate emits the section at version 2 and that the sample config
still validates.
Follow-up (not in this PR)
Reading a node requires AdminLevel while that node's posture requires only
UserLevel, so a UserLevel user can read posture but not the node. That
predates this work and looks unintended. Agreed plan is to raise posture to
AdminLevel once the remaining MCP phases land, rather than lower get_node.
Still to come: write tools behind an explicit opt-in, and routing MCP tool calls
through pkg/auditlog so agent activity is distinguishable from SPA/CLI access.
jmpsec/osctrlGitHub
09/04/2026, 10:06 AM