<#1024 Add opt-in write tools to the MCP server (r...
# osctrl
g
#1024 Add opt-in write tools to the MCP server (run_query, expire_query, complete_query, tag_node) Pull request opened by javuto Add write tools to the MCP server, off by default Adds four mutating MCP tools —
run_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:
Copy code
{
  "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/osctrl