Module 07 — LLM-Native PowerShell & an MCP Surface¶
Type 9 · Tool-Build — build a real, reusable tool: an MCP server that exposes Vigil's read-only hunt verbs to an LLM, and treats everything the model sends back — tool arguments and the results you hand it — as untrusted input, validated with the same typed-object discipline you use on event data. Go to the hands-on lab → · Cheat sheet →
Last reviewed: 2026-07
PowerShell for Security — the copilot will happily wire your hunt module to an LLM in five minutes; your edge is the trust boundary it forgets to draw.
In 60 seconds
You're going to make Vigil callable by an LLM. The Model Context Protocol (MCP) is the open
standard for that: a server advertises a set of tools (name + JSON-Schema for the arguments),
and a model — through an MCP client — picks a tool and fills in the arguments. The whole module turns
on one idea: the tool arguments the model sends are untrusted input, exactly like a raw event log
is. A model can be prompt-injected, can hallucinate a path, can emit a filter you never anticipated —
and if your handler passes that straight into Get-Content, a filesystem call, or (worse) something
executable, you've built the LLM equivalent of SQL injection. You'll expose only read-only verbs,
validate every argument against an allow-list / [ValidateSet] / typed parse before it touches
anything, and prove with Pester that a hostile argument is rejected by validation, not by luck. The
anchor is the Model Context Protocol specification itself.
Why this matters¶
Connecting a hunt tool to an LLM is the obvious next move — "let me just ask the model to pull the
suspicious events" — and the copilot will scaffold it instantly. That's the problem. The scaffold treats
the model as a trusted caller: it takes the model's path argument and reads that file, takes the model's
filter and runs it, sometimes takes a model-supplied command string and executes it. Every one of those
is a place where the model's output crosses into your system unchecked. And the model's output is not
trustworthy input — it is the least trustworthy input in the whole pipeline, because an attacker who lands
a prompt injection anywhere upstream (a malicious event Message, a poisoned document in the model's
context) can steer what arguments the model sends to your tool. This is the tool-calling analog of the
confused-deputy problem: your MCP server has Vigil's privileges, and the model is telling it what to do.
The whole track's discipline — emit and validate typed objects at every edge — was built for exactly this
edge. In Module 02 you validated event fields with parameter-validation attributes; in Module 03 you
validated untrusted telemetry; here you validate untrusted LLM output with the same muscle. The tool
surface you expose is a security boundary, and the argument validator is the guard on it. Get this right and
Vigil becomes an AI-callable hunt tool you can actually trust; get it wrong and you've handed a
prompt-injectable model a shell into your telemetry.
Objective¶
Build an MCP server that exposes Vigil's read-only hunt verbs (e.g. Get-VigilEvent and an enrichment
lookup) as MCP tools, with a tool-handler that validates every model-supplied argument against an
allow-list / [ValidateSet] / typed parse before it reaches any cmdlet — and never exposes a
state-changing verb over the surface. Prove with Pester that a well-formed argument succeeds and a
malformed or hostile argument is rejected by validation, offline, with no live model in the loop.
The core idea¶
An MCP tool definition is a contract, and the argument schema is the front door — but the schema is a
hint, not a guard. MCP is refreshingly small: a server speaks JSON-RPC 2.0 (over stdio for a local server,
or Streamable HTTP for a remote one), answers tools/list with an array of tool definitions — each a
name, a description, and an inputSchema (JSON Schema) — and answers tools/call by running the named
tool with the model's arguments. The inputSchema tells the model what shape to send; it does not
enforce anything on your side. A model that ignores the schema, or is steered to, will send you whatever it
wants, and your handler receives it. So the schema is documentation for the model; the validation in your
handler is the security control. Treat them as two separate jobs — advertise a tight schema and
re-validate every argument as if the schema didn't exist.
Validate LLM output the same way you validate any untrusted input: parse, don't trust; allow-list, don't
block-list; type it before you use it. This is not new machinery — it's the parameter-validation discipline
from Module 02 applied to a new source of dirt. A path argument is not a string you pass to Get-Content;
it's a candidate you resolve, confine to an allowed root, and reject if it escapes (.., absolute paths, a
symlink out). A severity argument is not free text; it's a [ValidateSet('Low','Medium','High')] — anything
else is rejected. A filter is not a scriptblock you Invoke-Expression on (you never do that anyway); it's
a set of typed, named fields the model fills in, each validated. The move is always: take the loosest thing
the model can send, and narrow it to the tightest thing your cmdlet needs, rejecting everything in between.
An argument that fails validation must fail loudly and safely — an MCP error result the model can see, not
a silent coercion that runs a slightly-wrong command.
Read-only is a property of the surface, not a hope about the model. The single most important design
decision is which verbs you expose. Vigil's hunt verbs are Get-, Read-, an enrichment lookup — all
read-only by construction. You do not expose Remove-, Set-, Invoke-, or anything that changes state,
and you enforce that at the surface: the tool registry is an explicit allow-list of read-only handlers, so
even if the model asks for a destructive action, there is no tool to call. This is defense in depth — the
allow-list means a prompt-injected model's worst case is reading data it's allowed to read, not deleting your
logs. (Track 12 goes deep on securing the AI system around this — sandboxing, monitoring, prompt-injection
defense end to end. This module owns the PowerShell side: building the tool surface well and validating the
argument edge. When you want the securing-AI depth, that's Track 12's job; here, stay on the tool
engineering.)
MCP transports — stdio vs. Streamable HTTP, and why the lab uses stdio
An MCP server can speak over stdio (the client launches the server as a subprocess and talks
JSON-RPC over stdin/stdout — ideal for a local tool) or Streamable HTTP (the server is a network
endpoint — for remote/shared tools). The protocol is identical; only the transport differs. The lab
implements a minimal stdio JSON-RPC handler in PowerShell so make demo can drive it entirely offline —
no network, no live model. The prose shows how you'd point a real MCP client (Claude Desktop, or the
Anthropic API's MCP connector) at the same handler; the validation layer you build is transport-agnostic
and is the part that matters.
Learn (~2–3 hrs)¶
The protocol (do this first — it's the contract you're implementing)
- Model Context Protocol — "Architecture overview" (~20 min) — the host/client/server model, and why tools are server-advertised, not baked into the model. Read this before you touch code; it frames the whole trust boundary.
- MCP specification — "Server Features → Tools" (~25 min)
— the exact shape of
tools/listandtools/call, theinputSchema, and — read this part carefully — the spec's own security note that tool inputs are untrusted and servers must validate them. This is the primary source for the module's whole thesis. - MCP — "Build an MCP server" quickstart (~25 min)
— skim the stdio JSON-RPC flow (
initialize,tools/list,tools/call) in any reference SDK so the PowerShell handler you write feels familiar; you're re-implementing this minimal loop.
Wiring an LLM to it (so you know what you're defending)
- Anthropic — "MCP connector" (~15 min)
— how the Claude API reaches an MCP server from a
messages.createcall (mcp_servers+ anmcp_toolset, betamcp-client-2025-11-20). Read it to understand the real caller — a model, through a client, choosing your tool and filling its arguments — so the "arguments are untrusted" framing is concrete. Use a current model id such asclaude-opus-4-8if you try it.
The discipline (why validation, not schemas, is the control)
- OWASP — "LLM01:2025 Prompt Injection" (~15 min)
— the canonical statement of why model output is untrusted: an attacker upstream can steer what the model
asks your tool to do. Read the "indirect prompt injection" part — that's the exact path from a poisoned
event
Messageto a hostile tool argument. - PowerShell docs — "about_Functions_Advanced_Parameters" (validation attributes) (~15 min)
— re-read the
ValidateSet/ValidatePattern/ValidateScriptsection with new eyes: these are your MCP-argument guards. The same attributes that validated event fields in Module 02 validate model output here.
Key concepts¶
- MCP tool =
name+description+inputSchema; the schema is a hint to the model, not a guard on your side — validation lives in your handler. - LLM output (arguments and results) is untrusted input — a prompt-injected/hallucinating model can send anything; parse, don't trust.
- Allow-list, don't block-list —
[ValidateSet], typed parse, path confinement; take the loosest input and narrow it to the tightest your cmdlet needs. - Read-only is a property of the surface — expose only
Get-/Read-/lookup verbs; there is no destructive tool to call, even if the model asks. - No model output into anything executable — never
Invoke-Expressiona model-supplied string; a failed-validation argument returns a safe MCP error, not a coerced command.
AI acceleration¶
This is the module where the AI is on the other side of the boundary, so use the copilot to build the
server and then review it as an adversary. Have it generate the tool-handler, the tool registry, and the MCP
JSON-RPC scaffolding from a spec — then hunt for the exact defaults it ships: does it take the model's path
and read it without confining to an allowed root? does it advertise a schema but skip re-validation in the
handler, trusting the schema to enforce shape? did it register a Set- or Remove- verb "for
completeness"? did it pass a model-supplied field into a string it then executes? The tell is that the
generated handler treats the arguments dictionary as trusted the moment it's parsed from JSON. Your job is to
insert the validation layer between "parsed" and "used" — and to write the Pester test that proves a
hostile argument dies there. AI authors the plumbing; you own the trust boundary.
Check yourself
- Why is the
inputSchemain a tool definition not sufficient as a security control, and where does the actual enforcement have to live? - Give a concrete path from an indirect prompt injection (a poisoned event
Message) to a hostile argument arriving at yourGet-VigilEventtool. What single design decision makes the worst case "reads data it's allowed to read" instead of "deletes the logs"? - In your own words: what does validating LLM tool arguments have in common with validating a raw
.evtxrecord, and why is that the same skill?
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).