Skip to content

Commit a145d9f

Browse files
committed
expose read-only MCP summary resources
1 parent 6e1577f commit a145d9f

11 files changed

Lines changed: 362 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1616
- `linuxagent check` now reports MCP and Skill status and fails fast when Skill
1717
manifests are missing, invalid, or contain read-only runbooks rejected by
1818
policy.
19+
- MCP now exposes configurable read-only resources for runbook and Skill
20+
summaries without returning command strings, full guidance bodies, or
21+
execution handles.
1922
- Workspace tool activity now includes concise evidence snippets from completed
2023
`read_file`, `list_dir`, and `search_files` calls. No-change file-plan
2124
answers also include the cited evidence so operators can see which file lines

configs/default.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ mcp:
8080
tools:
8181
- linuxagent.policy.classify
8282
- linuxagent.audit.verify
83+
resources:
84+
- linuxagent://runbooks/summary
85+
- linuxagent://skills/summary
8386

8487
skills:
8588
enabled: false

configs/example.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ mcp:
176176
tools:
177177
- linuxagent.policy.classify
178178
- linuxagent.audit.verify
179+
resources:
180+
- linuxagent://runbooks/summary
181+
- linuxagent://skills/summary
179182

180183
skills:
181184
# Declarative local YAML manifests only. No Python hooks, shell hooks, remote

docs/design/mcp-server.md

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ This design follows the MCP tool model: the server declares a `tools`
1313
capability, clients discover tools with `tools/list`, and clients invoke tools
1414
with `tools/call`.
1515

16+
It also exposes bounded read-only resources for public capability summaries.
17+
Resources are not execution handles.
18+
1619
## Threat Model
1720

1821
An MCP client is model-facing software. Tool calls may be triggered by model
@@ -39,11 +42,14 @@ mcp:
3942
tools:
4043
- linuxagent.policy.classify
4144
- linuxagent.audit.verify
45+
resources:
46+
- linuxagent://runbooks/summary
47+
- linuxagent://skills/summary
4248
```
4349
44-
`transport` currently accepts only `stdio`. `tools` is an explicit allowlist:
45-
unknown tool names fail config validation, and tools omitted from the list are
46-
not returned by `tools/list` or callable through `tools/call`.
50+
`transport` currently accepts only `stdio`. `tools` and `resources` are explicit
51+
allowlists: unknown names fail config validation, and entries omitted from the
52+
list are not returned by list methods or callable/readable by clients.
4753

4854
The default config enables the stdio server with both read-only tools. Setting
4955
`mcp.enabled: false` makes `linuxagent mcp` fail closed instead of starting a
@@ -61,6 +67,17 @@ capabilities, matched rules, approval requirement, and whitelist eligibility.
6167
Audit verification returns validity, record count, tamper line, reason, and the
6268
configured audit path.
6369

70+
## Exposed Resources
71+
72+
| Resource | Behavior | State mutation |
73+
|---|---|---|
74+
| `linuxagent://runbooks/summary` | Returns runbook ids, titles, and step purpose/read-only flags | None |
75+
| `linuxagent://skills/summary` | Returns Skill name/version/description/permissions/guidance presence/runbook ids | None |
76+
77+
Resources intentionally return summaries. They do not expose command strings,
78+
planner guidance bodies, execution results, raw audit logs, config secrets, or
79+
filesystem content.
80+
6481
## Non-Exposed Capabilities
6582

6683
These remain intentionally unavailable over MCP:
@@ -70,6 +87,8 @@ These remain intentionally unavailable over MCP:
7087
- file patch application
7188
- SSH cluster execution
7289
- raw audit record search
90+
- runbook command-string export
91+
- full Skill planner guidance export
7392
- raw secrets, provider keys, config values, or environment values
7493

7594
If execution is added later, it must call the same graph/service path as the
@@ -103,10 +122,13 @@ The server supports:
103122
- `notifications/initialized`
104123
- `tools/list`
105124
- `tools/call`
125+
- `resources/list`
126+
- `resources/read`
106127
- `shutdown`
107128

108-
Unknown methods and unknown tools return JSON-RPC errors. Business validation
109-
failures inside a known tool return a tool result with `isError: true`.
129+
Unknown methods, tools, and resources return JSON-RPC errors. Business
130+
validation failures inside a known tool return a tool result with
131+
`isError: true`.
110132

111133
## Future Slices
112134

src/linuxagent/cli.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,14 @@ def _cmd_mcp(args: argparse.Namespace) -> int:
181181
print("error: mcp.enabled is false", file=sys.stderr)
182182
return 1
183183
container = Container(cfg)
184-
server = McpServer(container.policy_engine(), cfg.audit.path, tools=cfg.mcp.tools)
184+
server = McpServer(
185+
container.policy_engine(),
186+
cfg.audit.path,
187+
tools=cfg.mcp.tools,
188+
resources=cfg.mcp.resources,
189+
runbooks=container.runbook_engine().runbooks,
190+
skills=container.skill_manifests(),
191+
)
185192
return serve_stdio(server)
186193

187194

src/linuxagent/config/models.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121
model_validator,
2222
)
2323

24-
from ..mcp_tools import MCP_READ_ONLY_TOOL_NAMES, McpToolName
24+
from ..mcp_tools import (
25+
MCP_READ_ONLY_RESOURCE_URIS,
26+
MCP_READ_ONLY_TOOL_NAMES,
27+
McpResourceUri,
28+
McpToolName,
29+
)
2530
from ..sandbox.models import SandboxNetworkPolicy, SandboxProfile, SandboxRunnerKind
2631

2732
_FROZEN = ConfigDict(frozen=True, extra="forbid")
@@ -392,6 +397,7 @@ class McpConfig(BaseModel):
392397
enabled: bool = True
393398
transport: Literal["stdio"] = "stdio"
394399
tools: tuple[McpToolName, ...] = MCP_READ_ONLY_TOOL_NAMES
400+
resources: tuple[McpResourceUri, ...] = MCP_READ_ONLY_RESOURCE_URIS
395401

396402
@field_validator("tools")
397403
@classmethod
@@ -400,6 +406,15 @@ def _reject_duplicate_tools(cls, value: tuple[McpToolName, ...]) -> tuple[McpToo
400406
raise ValueError("mcp.tools cannot contain duplicate entries")
401407
return value
402408

409+
@field_validator("resources")
410+
@classmethod
411+
def _reject_duplicate_resources(
412+
cls, value: tuple[McpResourceUri, ...]
413+
) -> tuple[McpResourceUri, ...]:
414+
if len(set(value)) != len(value):
415+
raise ValueError("mcp.resources cannot contain duplicate entries")
416+
return value
417+
403418

404419
class SkillsConfig(BaseModel):
405420
model_config = _FROZEN

src/linuxagent/mcp_server.py

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,20 @@
1111
from . import __version__
1212
from .audit import verify_audit_log
1313
from .interfaces import CommandSource
14-
from .mcp_tools import AUDIT_TOOL_NAME, MCP_READ_ONLY_TOOL_NAMES, POLICY_TOOL_NAME, McpToolName
14+
from .mcp_tools import (
15+
AUDIT_TOOL_NAME,
16+
MCP_READ_ONLY_RESOURCE_URIS,
17+
MCP_READ_ONLY_TOOL_NAMES,
18+
POLICY_TOOL_NAME,
19+
RUNBOOKS_SUMMARY_RESOURCE,
20+
SKILLS_SUMMARY_RESOURCE,
21+
McpResourceUri,
22+
McpToolName,
23+
)
1524
from .policy import PolicyEngine
25+
from .runbooks import Runbook
1626
from .security import redact_record
27+
from .skills import SkillManifest
1728

1829
PROTOCOL_VERSION = "2025-06-18"
1930
SERVER_NAME = "linuxagent-mcp"
@@ -49,6 +60,20 @@
4960
},
5061
},
5162
}
63+
_RESOURCE_DEFINITIONS: dict[McpResourceUri, JsonObject] = {
64+
RUNBOOKS_SUMMARY_RESOURCE: {
65+
"uri": RUNBOOKS_SUMMARY_RESOURCE,
66+
"name": "LinuxAgent Runbook Summary",
67+
"description": "Read-only summary of configured LinuxAgent runbooks.",
68+
"mimeType": "application/json",
69+
},
70+
SKILLS_SUMMARY_RESOURCE: {
71+
"uri": SKILLS_SUMMARY_RESOURCE,
72+
"name": "LinuxAgent Skill Summary",
73+
"description": "Read-only summary of configured LinuxAgent Skill manifests.",
74+
"mimeType": "application/json",
75+
},
76+
}
5277

5378
JsonObject = dict[str, Any]
5479

@@ -58,6 +83,9 @@ class McpServer:
5883
policy_engine: PolicyEngine
5984
audit_path: Path
6085
tools: tuple[McpToolName, ...] = MCP_READ_ONLY_TOOL_NAMES
86+
resources: tuple[McpResourceUri, ...] = MCP_READ_ONLY_RESOURCE_URIS
87+
runbooks: tuple[Runbook, ...] = ()
88+
skills: tuple[SkillManifest, ...] = ()
6189

6290
def handle(self, request: JsonObject) -> JsonObject | None:
6391
method = request.get("method")
@@ -81,6 +109,10 @@ def _handle_request(self, method: str, request_id: Any, params: Any) -> JsonObje
81109
return _result(request_id, {"tools": list(_tools(self.tools))})
82110
if method == "tools/call":
83111
return self._call_tool(request_id, params)
112+
if method == "resources/list":
113+
return _result(request_id, {"resources": list(_resources(self.resources))})
114+
if method == "resources/read":
115+
return self._read_resource(request_id, params)
84116
if method == "shutdown":
85117
return _result(request_id, {})
86118
return _error(request_id, -32601, f"unknown method: {method}")
@@ -100,6 +132,18 @@ def _call_tool(self, request_id: Any, params: Any) -> JsonObject:
100132
return _result(request_id, _tool_result(self._verify_audit()))
101133
return _error(request_id, -32602, f"unknown tool: {name}")
102134

135+
def _read_resource(self, request_id: Any, params: Any) -> JsonObject:
136+
if not isinstance(params, dict):
137+
return _error(request_id, -32602, "resources/read params must be an object")
138+
uri = params.get("uri")
139+
if uri not in self.resources:
140+
return _error(request_id, -32602, f"unknown or disabled resource: {uri}")
141+
if uri == RUNBOOKS_SUMMARY_RESOURCE:
142+
return _result(request_id, _resource_result(uri, _runbook_summary(self.runbooks)))
143+
if uri == SKILLS_SUMMARY_RESOURCE:
144+
return _result(request_id, _resource_result(uri, _skill_summary(self.skills)))
145+
return _error(request_id, -32602, f"unknown resource: {uri}")
146+
103147
def _classify(self, arguments: JsonObject) -> JsonObject:
104148
command = arguments.get("command")
105149
if not isinstance(command, str) or not command:
@@ -173,7 +217,10 @@ def _initialize_result(params: Any) -> JsonObject:
173217
protocol_version = str(params["protocolVersion"])
174218
return {
175219
"protocolVersion": protocol_version,
176-
"capabilities": {"tools": {"listChanged": False}},
220+
"capabilities": {
221+
"tools": {"listChanged": False},
222+
"resources": {"subscribe": False, "listChanged": False},
223+
},
177224
"serverInfo": {"name": SERVER_NAME, "version": __version__},
178225
}
179226

@@ -182,6 +229,59 @@ def _tools(names: tuple[McpToolName, ...]) -> tuple[JsonObject, ...]:
182229
return tuple(_TOOL_DEFINITIONS[name] for name in names)
183230

184231

232+
def _resources(uris: tuple[McpResourceUri, ...]) -> tuple[JsonObject, ...]:
233+
return tuple(_RESOURCE_DEFINITIONS[uri] for uri in uris)
234+
235+
236+
def _resource_result(uri: str, payload: JsonObject) -> JsonObject:
237+
text = json.dumps(redact_record(payload), ensure_ascii=False, sort_keys=True)
238+
return {
239+
"contents": [
240+
{
241+
"uri": uri,
242+
"mimeType": "application/json",
243+
"text": text,
244+
}
245+
]
246+
}
247+
248+
249+
def _runbook_summary(runbooks: tuple[Runbook, ...]) -> JsonObject:
250+
return {
251+
"runbooks": [
252+
{
253+
"id": runbook.id,
254+
"title": runbook.title,
255+
"steps": [
256+
{
257+
"purpose": step.purpose,
258+
"read_only": step.read_only,
259+
}
260+
for step in runbook.steps
261+
],
262+
}
263+
for runbook in runbooks
264+
]
265+
}
266+
267+
268+
def _skill_summary(skills: tuple[SkillManifest, ...]) -> JsonObject:
269+
return {
270+
"enabled": bool(skills),
271+
"skills": [
272+
{
273+
"name": skill.name,
274+
"version": skill.version,
275+
"description": skill.description,
276+
"permissions": list(skill.permissions),
277+
"has_planner_guidance": bool(skill.planner_guidance),
278+
"runbooks": [runbook.id for runbook in skill.runbooks],
279+
}
280+
for skill in skills
281+
],
282+
}
283+
284+
185285
def _tool_result(payload: JsonObject) -> JsonObject:
186286
return {
187287
"content": payload["content"],

src/linuxagent/mcp_tools.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
1-
"""MCP tool name constants shared by config and server code."""
1+
"""MCP surface constants shared by config and server code."""
22

33
from __future__ import annotations
44

55
from typing import Literal
66

77
McpToolName = Literal["linuxagent.policy.classify", "linuxagent.audit.verify"]
8+
McpResourceUri = Literal["linuxagent://runbooks/summary", "linuxagent://skills/summary"]
89

910
POLICY_TOOL_NAME: McpToolName = "linuxagent.policy.classify"
1011
AUDIT_TOOL_NAME: McpToolName = "linuxagent.audit.verify"
1112
MCP_READ_ONLY_TOOL_NAMES: tuple[McpToolName, ...] = (POLICY_TOOL_NAME, AUDIT_TOOL_NAME)
13+
RUNBOOKS_SUMMARY_RESOURCE: McpResourceUri = "linuxagent://runbooks/summary"
14+
SKILLS_SUMMARY_RESOURCE: McpResourceUri = "linuxagent://skills/summary"
15+
MCP_READ_ONLY_RESOURCE_URIS: tuple[McpResourceUri, ...] = (
16+
RUNBOOKS_SUMMARY_RESOURCE,
17+
SKILLS_SUMMARY_RESOURCE,
18+
)
1219

1320
__all__ = [
1421
"AUDIT_TOOL_NAME",
22+
"MCP_READ_ONLY_RESOURCE_URIS",
1523
"MCP_READ_ONLY_TOOL_NAMES",
1624
"POLICY_TOOL_NAME",
25+
"RUNBOOKS_SUMMARY_RESOURCE",
26+
"SKILLS_SUMMARY_RESOURCE",
27+
"McpResourceUri",
1728
"McpToolName",
1829
]

tests/unit/test_cli_and_container.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,20 +267,36 @@ def test_audit_verify_command_reports_tamper(
267267

268268

269269
def test_mcp_command_starts_stdio_server(monkeypatch: pytest.MonkeyPatch) -> None:
270-
cfg = AppConfig.model_validate({"mcp": {"tools": ["linuxagent.policy.classify"]}})
270+
cfg = AppConfig.model_validate(
271+
{
272+
"mcp": {
273+
"tools": ["linuxagent.policy.classify"],
274+
"resources": ["linuxagent://skills/summary"],
275+
},
276+
"telemetry": {"enabled": False, "exporter": "none"},
277+
}
278+
)
271279
calls: list[tuple[str, Path]] = []
272280

273281
monkeypatch.setattr(cli, "load_config", lambda **_: cfg)
274282
monkeypatch.setattr(
275283
cli,
276284
"serve_stdio",
277-
lambda server: calls.append(("serve", server.audit_path, server.tools)) or 0,
285+
lambda server: calls.append(("serve", server.audit_path, server.tools, server.resources))
286+
or 0,
278287
)
279288

280289
code = cli.main(["mcp"])
281290

282291
assert code == 0
283-
assert calls == [("serve", cfg.audit.path, ("linuxagent.policy.classify",))]
292+
assert calls == [
293+
(
294+
"serve",
295+
cfg.audit.path,
296+
("linuxagent.policy.classify",),
297+
("linuxagent://skills/summary",),
298+
)
299+
]
284300

285301

286302
def test_mcp_command_rejects_disabled_server(

0 commit comments

Comments
 (0)