GitHub
09/04/2026, 11:25 AMrun_query, expire_query, complete_query, tag_node —
opt-in on both the stdio binary and the hosted /api/v1/mcp endpoint.
Opt-in by construction, not just by config
NewServer(b Backend, version) returns a read-only server. Write tools only exist if a
caller also passes WithWrites(w WriteBackend) (pkg/mcp/server.go). WriteBackend is a
separate interface from the read-only Backend, so no refactor or config mistake can
register them silently — the type system has to be asked.
Two switches drive it, deliberately separate from `mcp.enabled`:
mcp:
enabled: false
allowWrites: false
or --mcp-allow-writes / MCP_ALLOW_WRITES (hosted), --allow-writes /
OSCTRL_MCP_ALLOW_WRITES (stdio binary). Enabling MCP does not enable writes; a
config test (TestMCPWritesOffWhenOnlyEnabled) pins that. The server instructions
only mention write capability when it's actually registered.
Four guards on run_query, on top of osctrl's own permission check
Each came from reading QueriesRunHandler rather than guessing:
| Guard | Why |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A target is required | osctrl reads "no selectors" as the whole environment. An omitted argument would silently become a fleet-wide query. all_nodes: true is the explicit way to mean it. |
| Every query expires | osctrl treats ExpHours == 0 as never expires — it would keep being handed to nodes enrolling months later. Default 24h, max 168h, both enforced here. |
| Carve queries refused | They copy files off endpoints. osctrl gates them behind CarveLevel server-side, but file exfiltration isn't a capability this server should hand an agent — run carves from the SPA or CLI. |
| Never hidden | Queries scheduled through MCP are always visible, so an operator can see what an agent started. |
tag_node only ever writes `TagTypeCustom`; environment and platform tags are osctrl's
own derived values and are not forgeable through MCP.
Verified against the built binary against a stub API, not just unit tests — the wire
payload confirms both the expiry and hidden guards land:
{
"platform_list": [
"linux"
],
"query": "select * from uptime",
"hidden": false,
"exp_hours": 24
}
and the untargeted / carve-query cases are refused client-side before any request goes out.
Why writes are a separate risk tier, not just "more tools"
Read-only, the worst a malicious hostname achieves is a misleading answer. With writes
on, the loop closes: text an attacker planted on a monitored endpoint could talk an agent
into scheduling a query. The tool descriptions and server instructions tell the model
never to let content it read decide to write — but that's a mitigation, not the control.
The switch defaulting to off is the actual control. Documented in docs/mcp.md and the
sample config's comments.
Authorization is still not this package's job: run_query needs QueryLevel and
tag_node needs AdminLevel on the environment, enforced by osctrl-api's handlers
exactly as for the SPA or CLI — same design as the read tools and the hosted transport
from the previous PR.
Config
mcp.allowWrites added alongside mcp.enabled, threaded through the same four places
(APIConfiguration, ServiceParameters, loadedYAMLToServiceParams,
GenerateAPIConfigFile). ConfigVersion bumped to 3 (new field, per the policy in
pkg/config/version.go); both sample files updated.
Tests
Ten new tests in pkg/mcp/write_test.go over the SDK's in-memory transport, plus a
hosted-gate test and two config round-trip tests:
• TestWriteToolsAbsentWithoutOptIn / TestWriteToolsPresentWithOptIn — the load-bearing
pair; if the first ever fails, a read-only deployment is handing an agent the fleet
• TestRunQueryRequiresExplicitTarget, TestRunQueryRejectsContradictoryTargets,
TestRunQueryAlwaysBoundsExpiration, TestRunQueryRefusesCarves,
TestRunQueryIsNeverHidden
• TestQueryLifecycleTools, TestTagNodeUsesCustomTagType
• TestInstructionsMentionWritesOnlyWhenEnabled
• TestHostedWriteToolsGated (cmd/api) — mounting MCP doesn't by itself put mutating
tools on the wire
• TestMCPAllowWritesRoundTrips, TestMCPWritesOffWhenOnlyEnabled (cmd/api)
go build ./..., go vet ./... clean; all packages pass.
Follow-up (not in this PR)
Phase 4 remains: route MCP tool calls through pkg/auditlog so agent activity is
distinguishable from SPA/CLI access. Currently the underlying API calls are audited
normally, but nothing marks them as agent-originated.
jmpsec/osctrlGitHub
09/04/2026, 11:29 AM