<#1023 Serve MCP from `osctrl-api` at `/api/v1/mcp...
# osctrl
g
#1023 Serve MCP from `osctrl-api` at `/api/v1/mcp` Pull request opened by javuto Serve MCP from osctrl-api at
/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/osctrl