Secure your first MCP tool in 5 minutes
June 5, 2026 · View on GitHub
This walkthrough takes a brand-new reader from pip install to a working,
authorized, audited tool invocation in roughly five minutes. Every code block
is copy-pasteable; the runnable companion is
examples/tutorial.py (covered by CI).
The PyPI package is
weaver-kernelbut the Python import isweaver_kernel. We use both names in this document.
What you'll learn
By the end of this page you will have seen, in this order:
- How to register a capability and how its
safety_class,sensitivity, andallowed_fieldsshape authorization. - How a principal is created and why some attributes (like
tenant) are required for PII-tagged capabilities. - How to issue a signed token with
kernel.get_token(...). - How
kernel.invoke(...)returns a bounded Frame insummary,table, orhandle_onlymodes — and whyemailnever appears in any of them. - How to retrieve filtered raw rows by expanding a Handle.
- What a policy denial looks like and how to branch on its stable
reason_code. - How
kernel.explain(action_id)returns an audit ActionTrace. - How to swap the in-process driver for a real MCP server.
0. Install
pip install weaver-kernel
For the MCP section near the end, also install the optional extra:
pip install "weaver-kernel[mcp]"
Set a stable HMAC secret for the process. In production this should come from a real secret store; the example uses a fixed value so the output is reproducible:
import os
os.environ["WEAVER_KERNEL_SECRET"] = "tutorial-secret-do-not-use-in-prod"
1. Register a capability
A capability is the unit of authorization. The safety_class controls
which roles may call it. The sensitivity tag tells the policy and
firewall how to treat the data. allowed_fields is the projection the
firewall applies before any row reaches the LLM.
from weaver_kernel import (
Capability,
CapabilityRegistry,
ImplementationRef,
SafetyClass,
SensitivityTag,
)
registry = CapabilityRegistry()
registry.register(
Capability(
capability_id="billing.invoices.list",
name="List Invoices",
description="List recent invoices",
safety_class=SafetyClass.READ,
sensitivity=SensitivityTag.PII,
allowed_fields=["id", "customer_name", "amount", "status"],
tags=["billing", "invoices", "list"],
impl=ImplementationRef(driver_id="memory", operation="list_invoices"),
)
)
phone, and other non-listed columns will never reach the LLM even if the driver returns them.
2. Wire a driver and the kernel
InMemoryDriver keeps the tutorial offline. The same pattern works with
HTTPDriver or MCPDriver — see step 8.
from weaver_kernel import HMACTokenProvider, InMemoryDriver, Kernel, StaticRouter
from weaver_kernel.drivers.base import ExecutionContext
INVOICES = [
{"id": "INV-001", "customer_name": "Alice", "email": "alice@example.com", "amount": 120.0, "status": "paid"},
{"id": "INV-002", "customer_name": "Bob", "email": "bob@example.com", "amount": 540.0, "status": "unpaid"},
{"id": "INV-003", "customer_name": "Carol", "email": "carol@example.com", "amount": 75.0, "status": "paid"},
]
driver = InMemoryDriver()
driver.register_handler("list_invoices", lambda ctx: list(INVOICES))
kernel = Kernel(
registry=registry,
token_provider=HMACTokenProvider(secret="tutorial-secret-do-not-use-in-prod"),
router=StaticRouter(routes={"billing.invoices.list": ["memory"]}),
)
kernel.register_driver(driver)
3. Create a principal
The DefaultPolicyEngine requires a tenant attribute on the principal
for any PII-tagged capability. Without it, the grant is denied with
reason_code="missing_tenant_attribute".
from weaver_kernel import Principal
alice = Principal(principal_id="alice", roles=["reader"], attributes={"tenant": "acme"})
4. Grant a token
get_token runs the policy engine and returns a signed
CapabilityToken. No token, no invocation.
from weaver_kernel.models import CapabilityRequest
request = CapabilityRequest(capability_id="billing.invoices.list", goal="list recent invoices")
token = kernel.get_token(request, alice, justification="")
print(token.token_id, token.expires_at)
5. Invoke and observe the Frame
The default response_mode is "summary". The Frame holds compact
facts about the data plus a Handle the LLM can expand later.
import asyncio
frame = asyncio.run(kernel.invoke(token, principal=alice, args={"operation": "list_invoices"}))
for fact in frame.facts:
print("•", fact)
print("handle:", frame.handle and frame.handle.handle_id)
Try response_mode="table" to get a row preview that respects
allowed_fields. Try response_mode="handle_only" to skip the preview
entirely — the LLM gets only a reference. In every mode, email is
absent from the Frame, because it is not in allowed_fields.
table_frame = asyncio.run(
kernel.invoke(
kernel.get_token(request, alice, justification=""),
principal=alice,
args={"operation": "list_invoices"},
response_mode="table",
)
)
assert all("email" not in row for row in table_frame.table_preview)
6. Expand a Handle
Handles let the LLM stay inside its context budget while still pulling
specific rows or fields on demand. The expand query supports offset,
limit, fields, and an equality filter.
handle_frame = asyncio.run(
kernel.invoke(
kernel.get_token(request, alice, justification=""),
principal=alice,
args={"operation": "list_invoices"},
response_mode="handle_only",
)
)
expanded = kernel.expand(
handle_frame.handle,
query={"offset": 0, "limit": 2, "fields": ["id", "amount"]},
principal=alice,
)
print(expanded.table_preview)
# [{'id': 'INV-001', 'amount': 120.0}, {'id': 'INV-002', 'amount': 540.0}]
Security boundary. The
Firewallenforcesallowed_fieldswhen it builds thesummaryandtablepreviews, so disallowed columns never reach the LLM-safe Frame.HandleStore.expand()now also enforces the grant'sallowed_fieldsprojection: requesting a field outside the grant raisesHandleConstraintViolation. Theprincipalargument ensures handles are not bearer credentials — a handle bound to one principal cannot be expanded by another.
Asking for a disallowed field is rejected with a stable reason_code:
from weaver_kernel.errors import HandleConstraintViolation
try:
kernel.expand(handle_frame.handle, query={"fields": ["email"]}, principal=alice)
except HandleConstraintViolation as exc:
print(exc.reason_code) # 'handle_constraint_violation'
The same shape applies to limit over the grant's max_rows, a
filter that disagrees with the grant's scope, and a principal
mismatch (the last raises with
reason_code="handle_principal_mismatch").
7. Watch policy enforcement
Add a WRITE capability and try to call it as the reader principal. The
denial carries both a human-readable reason and a stable
reason_code your code can branch on.
from weaver_kernel.errors import PolicyDenied
registry.register(
Capability(
capability_id="billing.invoices.create",
name="Create Invoice",
description="Create a new invoice",
safety_class=SafetyClass.WRITE,
tags=["billing", "invoices", "create"],
impl=ImplementationRef(driver_id="memory", operation="create_invoice"),
)
)
try:
kernel.get_token(
CapabilityRequest(capability_id="billing.invoices.create", goal="create an invoice"),
alice,
justification="reader trying a write — should fail",
)
except PolicyDenied as exc:
print(exc.reason_code) # 'missing_role'
print(str(exc)) # "WRITE capabilities require the 'writer' or 'admin' role..."
Stable reason codes come from weaver_kernel.policy_reasons.DenialReason.
Tests should assert on the code, not on the human-readable string.
8. Audit with explain()
Every successful invocation creates an ActionTrace keyed by
frame.action_id. The trace records who, what, when, and which driver
served the request — the auditable half of weaver-spec invariant I-02.
trace = kernel.explain(frame.action_id)
print(trace.action_id, trace.capability_id, trace.principal_id, trace.driver_id)
9. Swap the driver for an MCP server
The kernel doesn't care whether the driver lives in-process, behind
HTTP, or behind an MCP server — capabilities, policy, tokens, and
firewall behave identically. To talk to a real MCP server, replace
InMemoryDriver with MCPDriver (full transport details, including
Streamable HTTP, live in docs/integrations.md):
from weaver_kernel.drivers.mcp import MCPDriver
driver = MCPDriver.from_stdio(
command="python",
args=["-m", "my_mcp_server"],
server_name="local-tools",
)
kernel.register_driver(driver)
# Discover the MCP server's tools and register each as an agent-kernel
# capability under a namespace. Set safety_class/sensitivity/allowed_fields
# on the resulting Capability objects to apply policy and the firewall.
capabilities = asyncio.run(driver.discover(namespace="billing"))
registry.register_many(capabilities)
That's the whole tutorial. From here:
docs/security.md— threat model, what HMAC tokens do and do not protect against.docs/context_firewall.md— redaction, summarization, and budget details.docs/capabilities.md— designing capabilities for large tool ecosystems.docs/integrations.md— full MCP and HTTP driver integration patterns.