Guardian MCP
by waleoxy
README.md
# Guardian MCP
The Alexa+ MCP server for Guardian — an AI household safety agent that
reasons about events, context, and policy before acting, rather than
just forwarding alerts.
## Architecture
```
Alexa+ ──(MCP tools/call)──► src/server.ts ──► src/tools.ts ──► decide() ──► store
Ring ──(EventBridge)────► src/lambda/ringEventHandler.ts ──► decide() ──► store
Dashboard (browser) ──(REST poll)──► src/server.ts /api/* ──► store
```
Two independent entry points — the synchronous MCP path (Alexa+ voice
turns) and the asynchronous event path (Ring → EventBridge → Lambda) —
both call the exact same `decide()` function and read/write the exact
same `store`. That's deliberate: the reasoning and the state are the
product; MCP and EventBridge are just two doors into it.
## What's in this repo
- `src/types/domain.ts` — household/event/policy/incident data model
- `src/store/` — storage layer, swappable:
- `types.ts` — `IGuardianStore` interface everything else codes against
- `memoryStore.ts` — in-memory, seeded, zero AWS dependency (default)
- `dynamoStore.ts` — real DynamoDB implementation
- `index.ts` — picks the backend via `STORE_BACKEND` env var
- `seedData.ts` — the demo household, shared by both backends
- `src/decisionEngine.ts` — rule-based decision engine (deterministic, no
AWS), including pattern-of-events escalation (Scenario D: no single
event is alarming, but a cluster of unusual events in a short window is)
- `src/bedrockDecisionEngine.ts` — Bedrock-backed decision engine, with
automatic fallback to the rule engine if the Bedrock call fails or times out
- `src/decide.ts` — picks the engine via `DECISION_ENGINE` env var
- `src/policyCompiler.ts` — Guardian Rules: turns a plain-language rule
("if Mom doesn't respond after two attempts, notify me") into a
structured `Policy` via Bedrock — no rule-based fallback here, since
guessing a wrong structured policy is worse than reporting it couldn't
parse the rule
- `src/tools.ts` — the 13 MCP tools Alexa+ calls
- `src/app.ts` — the Express app (MCP endpoint, dashboard REST API,
dashboard static files) — no `listen()` call, shared by local dev and Lambda
- `src/server.ts` — local-dev entry point (`app.listen()`)
- `src/lambda/mcpHandler.ts` — wraps the same app for Lambda + API
Gateway via `serverless-http` — this is what Alexa+ actually talks to once deployed
- `src/lambda/ringEventHandler.ts` — EventBridge-triggered Lambda for
the Ring ingestion path
- `src/scripts/seedDynamo.ts` — populates DynamoDB tables with the demo household
- `public/index.html` — the dashboard (house status, active incidents,
Contact/Escalate buttons) — this is your Fire TV screen; cast the
browser tab if you don't build a native app
- `template.yaml` — AWS SAM: both Lambdas, all 6 DynamoDB tables, the
EventBridge rule, least-privilege IAM, and a CloudWatch dashboard
- `addon-package/addon.json` — the Alexa+ add-on manifest, built to
Amazon's real MCP Toolkit schema, with placeholders for what only you can provide
- `src/__tests__/decisionEngine.test.ts` — automated tests for all four
demo scenarios (A/B/C/D) plus the fallback path and two regression
guards for real bugs the tests themselves caught (see below)
- `src/__tests__/store.test.ts` — regression test for a bug in
`addEvent`'s timestamp handling, found via live testing rather than
the existing suite (see below)
- `.github/workflows/ci.yml` — typecheck + build + test on every push/PR
- `infra/*.sh` — raw AWS CLI fallback if you need to provision outside of SAM
## Run it locally (zero AWS dependency)
```bash
npm install
npm run build
npm start
# Guardian MCP server listening on http://localhost:3000/mcp
```
- Dashboard: open `http://localhost:3000/`
- Health check: `GET /health`
This runs entirely on `MemoryStore` + the rule-based decision engine —
no AWS credentials needed to develop against it.
## Try the reasoning pipeline directly
```bash
curl -s -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {
"name": "report_event",
"arguments": { "source": "ring", "type": "person_detected", "location": "front_door" }
}
}'
```
This is the "unknown visitor at the door" scenario from the demo
script. With the seeded household (Mary home and marked vulnerable),
it returns a `tier: "ask"` decision asking whether Mary needs
anything — Scenario C from the design doc — and the reasoning always
includes "I won't unlock the door without your confirmation" no matter
which tier resolves the event. No Alexa+ or real Ring device required
to see it work.
Call `initialize` then `tools/list` first if you want the full tool
catalog and schemas (any MCP client, or curl with an
`Accept: application/json, text/event-stream` header, works).
## Turning on Bedrock
```bash
export DECISION_ENGINE=bedrock
export AWS_REGION=us-east-1
export BEDROCK_MODEL_ID=anthropic.claude-3-5-sonnet-20241022-v2:0
npm start
```
If the Bedrock call fails for any reason (permissions, model not
enabled in-region, network blip mid-demo), it automatically falls back
to the rule engine and logs why — the demo doesn't crash, it just
gets slightly less nuanced for that one decision.
## Teaching Guardian a new rule (Guardian Rules)
```bash
curl -s -X POST http://localhost:3000/mcp -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {
"name": "add_policy",
"arguments": { "rule": "If someone knocks after 10pm, always wake me even if it seems minor" }
}
}'
```
Requires `DECISION_ENGINE=bedrock` (or just Bedrock credentials present
— the compiler always uses Bedrock, there's no rule-based fallback for
this one). Without credentials it fails loudly and explains why,
rather than crashing or silently guessing.
## Bugs and gaps found along the way
Worth documenting honestly rather than glossing over:
1. **The hard constraint was too broad.** `p-never-auto-unlock` matched
*any* `person_detected` event, so a scheduled visitor (e.g. the
cleaner, arriving in their normal Monday window) would incorrectly
trigger the same "I won't unlock the door" refusal as a genuinely
unknown visitor — Scenario A from the design doc was never actually
achievable. Fixed by adding an expected-visitor context check that
runs first. *(Found by writing tests.)*
2. **A catch-all policy was silently matching everything.**
`p-quiet-hours` had an empty `appliesTo`, which the original
`matches()` function treated as "matches any event" — meaning it
won before the intended hardcoded default ever ran, making that
default dead code. Fixed by requiring policies to specify at least
one criterion to auto-match. *(Found by writing tests.)*
3. **Scenarios B and C didn't exist, and the "refusal" demo was
tier-mislabeled.** The original build only ever implemented
Scenario A (expected visitor) and D (pattern escalation) — B (owner
away → simple notify) and C (vulnerable member home → ask if
assistance is needed) were never coded. Worse: the "AI refuses an
unsafe action" demo moment was hardcoded to `tier: "escalate"` at
95% confidence, which doesn't match the design doc's own transcript
for that exact situation ("I'll notify you and continue
monitoring" — restrained, not urgent) and contradicts the Bedrock
engine's own system prompt, which explicitly says to use escalate
sparingly. Fixed by implementing `checkVulnerableMemberHome` (C) and
`checkOwnerAway` (B) as real context checks, and folding the
never-auto-unlock constraint into normal policy resolution so its
*tier* follows context (inform/ask) while the refusal language
itself stays constant across every tier. *(Found by a direct
question — "did we only have A and D, is there B and C" — not by
the test suite. The lesson: passing tests only prove the code does
what the tests assume it should; they don't catch a scenario that
was never implemented or a demo that was confidently mislabeled.)*
All three are now regression-tested in `decisionEngine.test.ts`.
4. **Adding the `timestamp` override itself introduced a new bug.**
`tools.ts`'s `{ source, type, location, timestamp }` object literal
always creates an own `timestamp` property — `undefined` when the
caller omits it, but present nonetheless. `addEvent`'s object spread
was ordered `{ timestamp: computedDefault, ...input }`, so that
explicit `undefined` silently overwrote the computed default —
every event created without an explicit timestamp ended up with
*no* timestamp at all, which broke Scenario D's pattern detection
(it filters by timestamp). Caught by live-testing all four
scenarios end to end after the B/C fix, not by the existing test
suite — every existing test happened to always pass an explicit
timestamp. Fixed by reordering the spread so `id`/`timestamp` are
always assigned *after* the spread, and added a targeted regression
test (`store.test.ts`) that reproduces the exact input shape
`tools.ts` produces — verified to fail against the old code before
confirming the fix.
## Deploying (Lambda + API Gateway + DynamoDB + EventBridge)
Everything is wired in `template.yaml` (AWS SAM). One-time setup: install
the [SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html).
```bash
npm run build
sam build
sam deploy --guided
```
`sam deploy --guided` will prompt for a stack name and region, then
create:
- **`McpFunction`** — the MCP server (Lambda + HTTP API), the public
HTTPS endpoint Alexa+ talks to
- **`RingEventFunction`** — the Ring ingestion Lambda, subscribed to
an EventBridge rule matching `source: "guardian.ring"`
- 6 DynamoDB tables (members, visitors, policies, events, incidents, config)
- A CloudWatch dashboard (`guardian-agent`) with invocation/error/latency
widgets — including p50/p99 latency against Alexa+'s 500ms requirement
- IAM roles scoped to exactly what each function needs (DynamoDB CRUD
on its own tables, `bedrock:InvokeModel` scoped to Anthropic models —
not `bedrock:*`)
Start with `DecisionEngine=rules` (the default parameter) to confirm the
whole pipeline works with zero Bedrock dependency, then redeploy with
`DecisionEngine=bedrock` once you've confirmed model access:
```bash
sam deploy --parameter-overrides DecisionEngine=bedrock
```
After deploy, seed the tables (same demo household as local dev):
```bash
npm run seed:dynamo
```
The stack output `McpEndpoint` is the URL you need for the next step.
## Alexa+ track compliance
The Devpost rules for the Alexa+ track require: *"a working Agent Skill
or a self-hosted MCP server, implementing MCP spec version (minimum
acceptable version is 2025-11-25)."* Guardian satisfies this on the
MCP server branch — it negotiates `2025-11-25` correctly and is
deployed at a public HTTPS endpoint. No additional registration step
is required to meet the track requirement.
To verify spec compliance against the running server:
```bash
npx @modelcontextprotocol/inspector
# point it at https://h14vepqrzj.execute-api.us-east-1.amazonaws.com/mcp
# or http://localhost:3000/mcp for local dev
```
**Optional — Alexa+ MCP Toolkit (Private Preview)**: if you have
Private Preview access, `addon-package/addon.json` is already built to
Amazon's real add-on schema with all URLs filled in. Register and
deploy with:
```bash
npm install -g @alexa-ai/cli
alexa-ai configure # LWA OAuth, one-time
cd addon-package && alexa-ai deploy
```
This is an enhancement on top of an already-compliant submission, not
a requirement.
## Wiring in real Ring events
The EventBridge rule and Lambda subscription are already created by
`sam deploy` (see above) — nothing further to provision. Point your
Ring webhook/poller at a small adapter that calls EventBridge
`PutEvents` with `Source: "guardian.ring"`, `DetailType: "RingEvent"`,
and a `Detail` of `{ eventType, location }`. Keep Ring as the
observation source only — all reasoning stays in the decision engine,
per the product's core design principle (Ring produces observations,
Guardian produces decisions).
For quick manual testing without a real Ring device:
```bash
aws events put-events --entries '[{
"Source": "guardian.ring",
"DetailType": "RingEvent",
"Detail": "{\"eventType\":\"person_detected\",\"location\":\"front_door\"}"
}]'
```
`infra/dynamodb-tables.sh` and `infra/eventbridge-rule.sh` are kept as
a raw-CLI fallback if you ever need to provision outside of SAM (e.g.
debugging a single resource) — normal path is `sam deploy`.
## Docs
- [`SUBMISSION.md`](./SUBMISSION.md) — judge-facing writeup: problem, differentiation, architecture, AWS usage
- [`DEMO_SCRIPT.md`](./DEMO_SCRIPT.md) — both demos, timed, mapped to actual tool calls
- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — the extension points that make this reusable beyond Guardian specifically
- [`FRICTION_LOG.md`](./FRICTION_LOG.md) — template + one real entry, for the Open Source and general judging bonus
- [`LICENSE`](./LICENSE) — MIT
## Store listing assets
`addon-package/media/` has a generated icon set (all 6 required sizes)
and a carousel image, styled to match the dashboard — real files, not
placeholders, ready to upload to wherever you host static assets
(S3+CloudFront, GitHub Pages, etc.). Regenerate them with
`python3 brand-assets/generate_icons.py` and
`python3 brand-assets/generate_carousel.py` if you want to tweak the
look (both are plain PIL, no network dependency).
`addon-package/legal/privacy-policy.md` and `terms-of-use.md` are
drafts scoped to exactly what this codebase actually collects and
does — not generic boilerplate. Replace the bracketed placeholders,
get them reviewed, host them, then point `addon.json`'s
`privacyPolicyUrl` / `termsOfUseUrl` at the hosted versions.
## Build order status
1. ✅ MCP server + rule-based decision engine (zero AWS deps, tested)
2. ✅ Bedrock decision engine with automatic fallback
3. ✅ DynamoDB store, swappable via `STORE_BACKEND` env var
4. ✅ Ring → EventBridge → Lambda ingestion path
5. ✅ Dashboard (Fire TV screen)
6. ✅ Pattern-of-events escalation (Scenario D)
7. ✅ NL policy compiler (Guardian Rules)
8. ✅ SAM deployment (Lambda + API Gateway + DynamoDB + EventBridge + CloudWatch)
9. ✅ `addon.json` built to Amazon's real MCP Toolkit schema
10. ✅ Open-source packaging: LICENSE, CONTRIBUTING.md (real extension points, not boilerplate), FRICTION_LOG.md
11. ✅ Demo script, timed and mapped to tested tool calls, with a backup-video plan
12. ✅ Store listing assets (generated icon set + carousel image) and privacy policy / terms drafts scoped to what the code actually does
13. ✅ Judge-facing submission writeup (`SUBMISSION.md`)
14. **You**: record your demo video (MCP Inspector or curl against the
deployed endpoint fully satisfies the track requirement — a real
voice interaction via the bridge tool or Alexa+ Private Preview is
a bonus, not a requirement), fill in the friction log and the
AWS-feedback section of `SUBMISSION.md` as you go
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues