alexa-plus
Integrates with Amazon Bedrock (and the Alexa+ smart-home ecosystem): an optional Bedrock planner drives the tool-use loop via Bedrock's Converse API (default model amazon.nova-micro-v1:0), letting a real model decide which smart-home tools to call and in what order. Requests are proxied through the client's own Node process with BedrockRuntimeClient so AWS credentials are never exposed to browser code, and every model-chosen action still passes through the human Confirm/Decline gate before it can execute.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@alexa-plusPropose dimming the living room light to 50% and wait for my approval."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
alexa-plus: Smart Home Agent
An Alexa+ MCP server for the smart home where the agent proposes and only the owner's Confirm can execute, plus a simulated Alexa+ web client that talks to it over the wire.
Built for the Build, Ship, Shape: Amazon Developer Hackathon, Alexa+ track.
Demo video: https://youtu.be/VkIw_HWnEZE
Live MCP server:
http://16.176.3.215:3000/mcp(AWS EC2,ap-southeast-2; see Known limitations for latency)
Try it now — list the live server's eight tools with the off-the-shelf MCP Inspector (needs Node.js; nothing to clone):
npx -y @modelcontextprotocol/inspector --cli http://16.176.3.215:3000/mcp --method tools/list
npx -y @modelcontextprotocol/inspector --cli http://16.176.3.215:3000/mcp --method tools/call --tool-name list_devicesThe problem
An agent that can unlock the front door can unlock it by mistake, or because something it
read told it to. MCP already gives hosts two ways to be careful: tool annotations
(destructiveHint and friends), which are hints a host may act on, and elicitation, which
is a way to ask the user. Neither one stops a call the host decides to make anyway, and a
confirmation step inside the agent's own loop is something the agent's side of the wire
can skip.
This entry makes approval an enforcement step instead. The agent can discover devices, read state, check the home's automation policy and compose multi-device actions, and it cannot change anything on its own: every change is a proposal, and executing one needs a one-time token that only the owner can mint, on a route no MCP tool reaches, with a key the agent never holds. Every executed action goes into an append-only audit log with the actor and the reason.
Who it is for. Homes where more than one person has a say over the same devices: households running several brands of device, renters and landlords sharing a smart lock, and caregivers who need a record of who approved what. The route to them is an Alexa+ add-on, once the OAuth 2.1 + PKCE onboarding Alexa+ requires is built (not done yet, see Known limitations).
Related MCP server: AutoFlow Gateway
How it works
The server (server/) exposes eight tools over MCP's Streamable HTTP transport (spec
revision 2025-11-25), backed by a seeded 5-device registry (server/data/devices.json,
five fictional devices in three rooms):
Tool | What it does | What it does NOT do |
| Returns every device (or one room's): id, name, type, room, state, capabilities, supported actions. | Change any device, or invent devices that are not in the registry. |
| Returns one device's current state and | Change state, cache, or predict. An unknown id is an error, not a guess. |
| Checks a proposed | Execute, override the policy, or write an audit entry. |
| Returns executed actions, newest first, optionally for one device, capped at | Write, delete or redact entries; list declined or unconfirmed proposals. |
| Creates a proposal for one device action with its expected outcome, rationale and policy check. | Execute, change state, assume approval, or produce a usable token. |
| Executes a proposed action, only with a one-time | Run without a valid, matching, unused token, or override the policy. |
| Creates one proposal covering several device actions (e.g. "Good Night"), each policy-checked; if any is denied, the whole scene is blocked. | Execute, persist the scene, or assume approval. |
| Executes a confirmed scene's actions in order, one audit entry per action. | Run without a matching token, skip the policy re-check, or roll back earlier actions if a later one fails. |
Proposing never changes state. Executing requires a one-time confirmation_id — a token
minted ONLY by the owner approving the proposal through the owner-key REST route below
(never through an MCP tool call, and never by the agent itself).
execute_action/execute_scene refuse — with a narrated reason, never a silent no-op —
a call with no token, a token that matches no pending proposal, an already-used token, a
token approved for a different action, or an action the automation policy denies at
execute time.
Proposal REST routes (not MCP tools): POST /proposals/:id/approve mints the
token; POST /proposals/:id/reject declines without ever minting one; GET /proposals/:id reads a proposal's current status (never its token) and needs no key.
Approve and reject are the owner-only pair: they require the owner key the server was
started with (OWNER_KEY), sent as
Authorization: Bearer <key>: a missing or wrong key gets 401 and mints nothing, and a
server started without OWNER_KEY refuses every decision (503). A browser may call
/proposals/* only from an allowed origin (OWNER_ORIGINS, default the client's own
http://127.0.0.1:5173 and http://localhost:5173); /mcp stays open to any MCP client.
The design called for owner-only approve/reject commands that are never registered on
the agent; this entry's client is a web page rather than a CLI, so they are REST routes,
and the client's Confirm/Decline buttons call them with the key.
Tool annotations are hints; the token is the gate. Each tool carries MCP annotations
(the four reads are readOnlyHint, the two proposers are not destructive, the two
executors are destructiveHint), so a host that prompts on hints can do so. The server
never relies on them: execute_action/execute_scene refuse without the owner's token
whatever a host does with the hints.
Automation policy (server/src/policy.js, pure and stateless): an unknown device is
denied (unknown_device), an action the device does not support is denied
(unsupported_action), brightness outside 0–100 is denied (invalid_range), and a
thermostat target outside 60–80°F is denied (energy_limit); anything else is allowed
(no_restrictions). A proposal the policy denies is created with status
blocked_by_policy and can never be approved. The execute tools re-run the policy
immediately before changing anything, so an approval cannot carry an action past a policy
that has changed since.
State. A proposal moves awaiting_approval → approved (token minted) → executed
(token consumed), or awaiting_approval → rejected. An audit entry records
entry_id, timestamp, device_id, action, params, actor, reason, result
and the device's new_state. Devices, proposals and the audit log live in memory for
the life of the server process.
The client (client/) is a separate project — its own package.json, its own
npm install/npm start/npm test — that imports nothing from server/ and reaches it
only over HTTP. It renders an Alexa+-style conversation and walks the demo script
(below) with a scripted planner (no LLM required): every tool call it makes is a live
fetch() to the running server, and a proposed action pauses the conversation until you
click Confirm or Decline right there in the page — the demo runs one confirmed action
(dimming the living room light) and one declined action (unlocking the front door). A row
of device tiles above the conversation shows each device's state, rebuilt from the real
tool results the conversation received (client/src/device-state.js), so a Confirm
visibly moves the light to 50% and a Decline visibly leaves the door locked. Each tool
call's arguments and JSON result are one click away rather than expanded.
docs/architecture.md has the component diagram, the approval-gate sequence diagram, the full refusal matrix with the test that covers each row, and how this server maps onto Amazon's Alexa+ MCP onboarding requirements.
Stack: Node.js (server >= 20, client >= 20.10, since its test script uses
--test-concurrency) and npm. Server: Express 5 with the official
@modelcontextprotocol/sdk and Zod; tests with Jest and Supertest; lint with ESLint.
Client: React 18 loaded from a CDN import map (no bundler, no build step); tests with
Node's built-in node:test.
Setup
npm installPick an owner key: any long random string. The server and the client must be started with the same one.
export OWNER_KEY="$(node -e "console.log(require('node:crypto').randomBytes(24).toString('hex'))")"
echo "$OWNER_KEY" # copy it into the client's terminal belowRun
npm startStarts the MCP server on http://127.0.0.1:3000/mcp (override the port with PORT=<n>).
The endpoint accepts POST, GET, and DELETE per the Streamable HTTP transport spec.
Without OWNER_KEY the server still runs, but refuses every approve and decline.
If the client runs on another port or host, list its origin in OWNER_ORIGINS
(comma-separated) so the browser may call the owner routes.
Test
npm testRuns the conformance suite in server/test/ (47 tests in 7 suites): initialize
handshake, tools/list (including each tool's annotations), tools/call (including the
unknown-device and unknown-tool error paths, and a no-mutation check against the seeded
registry), session lifecycle (session id issuance, reuse, DELETE termination, and the
400-vs-404 distinction between a missing and a terminated/unknown session id), the
propose→approve/reject→execute lifecycle for both single actions and scenes, the policy
rules, and every way an execute_action/execute_scene call can be refused (no
confirmation_id, a token that matches no pending proposal, a proposal that was never
approved, a replayed already-used token, a token approved for a different action, and a
policy that denies the action at execute time), plus the owner-only approve/reject
routes themselves refusing an unknown or already-decided proposal, a missing or wrong
owner key, a server with no owner key, and a browser on an origin that is not allowed.
Lint
npm run lintVerify
bash verify.shChecks the root files, the MIT LICENSE, this README's sections and the submission
write-up, then runs the server's lint and tests, confirms client/ imports nothing from
server/, and runs the client's tests.
Simulated Alexa+ client
In a second terminal, with the server already running from the steps above and the same
OWNER_KEY exported:
cd client && npm install && npm startThen open http://127.0.0.1:5173 in a browser (override the client's own port with
PORT=<n>; it defaults to talking to the server at http://127.0.0.1:3000/mcp, editable
in the page). Click "Start demo conversation" to run the demo script. It
will pause twice with a "Confirmation needed" panel — click Confirm the first time
(dimming the living room light — this one actually executes, and the light's tile moves
to 50%) and Decline the second time (unlocking the front door — the agent narrates
that the door stays locked, and its tile never changes).
client/server.js templates OWNER_KEY into the page it serves, because the page's own
Confirm and Decline buttons are what send it. That is why it listens on 127.0.0.1 only
(HOST overrides that; don't point it at a public interface) and serves nothing to a
request whose Host header names another machine. If the client has no key,
the page says so and the server refuses its Confirm.
client/ has no bundler or build step — React loads from a CDN import map in
index.html. Its one npm dependency, @aws-sdk/client-bedrock-runtime, is used only by
client/server.js under PLANNER=bedrock (see below), so npm install there does real
work; "one command each" (npm install && npm start) still works identically for server
and client.
To run the client's own tests (25 tests; the integration tests spawn the real server as a separate process and drive the full script against it over HTTP, including a scripted confirm, a scripted decline, and a client without the owner key being refused):
cd client && npm testPLANNER=bedrock — the Bedrock tool-use-loop planner
The scripted planner above is the default and needs no LLM. An alternative planner lets a real model (Amazon Bedrock's Converse API, tool-use loop) choose which tools to call and in what order, instead of walking a fixed script — set it when starting the client:
PLANNER=bedrock AWS_REGION=ap-southeast-2 BEDROCK_MODEL_ID=amazon.nova-micro-v1:0 npm startAll three env vars are optional (defaults shown above — AWS_REGION defaults to
ap-southeast-2, BEDROCK_MODEL_ID to amazon.nova-micro-v1:0). Nova Micro is the
cheapest Bedrock model confirmed to support Converse tool-use as of 2026-09-10
($0.035/$0.14 per 1M input/output tokens); Claude Haiku 4.5 also supports it at roughly
30x the cost (~$1/$5), and the original default, Claude 3.5 Sonnet, is no longer in
Bedrock's model catalog. ap-southeast-2 hosts Nova Micro in-Region, with no cross-region inference
profile needed. The tradeoff: Nova Micro is a much smaller model, so its tool-call
reliability on complex turns is less proven — override BEDROCK_MODEL_ID if that shows
up.
client/server.js reads all three once at startup, but only PLANNER and
BEDROCK_MODEL_ID get templated into window.__ALEXA_PLUS_CONFIG__ in the served HTML
(app.js reads that global and dynamically imports client/src/bedrock-planner.js only
when planner: 'bedrock'). AWS_REGION is deliberately not templated — it only
matters to the real BedrockRuntimeClient server.js constructs for itself, and the
browser never constructs one at all.
What does not change: no AWS credentials are ever read, embedded, or reachable by
browser-served code. The browser never constructs an AWS SDK client at all — it POSTs
the Converse request to this same origin's POST /bedrock/converse (client/server.js),
which is the one place BedrockRuntimeClient is actually constructed, under Node, where
the AWS SDK's standard credential chain (environment variables, ~/.aws/credentials,
IMDS) can resolve safely. Every tool call the model makes still goes through the exact
same mcpClient.callTool() chokepoint the scripted planner and the UI use, and every
proposed action still has to pass through the exact same human Confirm/Decline gate to
get a confirmation_id — the model can reason about anything, but it can never mint its
own token, redirect an execution to a different device/action than what was actually
proposed and confirmed, replay a token, or clobber one pending proposal with another when
two are in flight at once (see client/src/bedrock-planner.js's header comment for
exactly how that's enforced). npm test needs no LLM key and makes no network call to
AWS: client/test/bedrock-planner.test.js and client/test/bedrock-proxy-route.test.js
both drive the loop and the proxy route respectively against a mocked bedrockClient
returning scripted Converse-shaped responses.
Live browser execution works (corrected 2026-09-11). An earlier version of this
section documented the opposite as a "known, deliberate gap" — that was true at the time
(the browser tried to construct BedrockRuntimeClient itself, which cannot resolve
credentials and cannot even resolve the SDK's import without a bundler) but was never
actually verified live until the client was opened in a real browser tab and failed
exactly that way. The fix moved the AWS call server-side; a real click-through now gets
as far as a real AWS SDK error (e.g. Could not load credentials from any providers, on
a machine with no local AWS credentials configured) delivered cleanly through the UI's
normal error path, not a browser crash. Getting an actual model response additionally
needs the machine running npm start in client/ to have real AWS credentials
available to the Node process (local ~/.aws/credentials / env vars in dev, an IAM role
if this process is ever deployed). Separately, the same Converse call shape was run
against real AWS from AWS CloudShell with that environment's own credentials and returned
a genuine tool-use decision (list_devices) with HTTP 200.
What has not been run yet is the whole loop with a real model: PLANNER=bedrock
choosing tools against this MCP server and stopping at the Confirm gate, with real
credentials. The next section is the one-command way to do that.
bedrock-cli.js — the Bedrock planner in a terminal
client/bedrock-cli.js runs the same runBedrockConversation() loop under Node, with
the person at the terminal as the confirm gate (type c or d at each proposal), and
prints a transcript: every tool the model called with its real result, and every gate
decision. It is meant for a shell that already holds AWS credentials, such as AWS
CloudShell, so no browser and no local credential file is involved:
# server, in the background (or point the CLI at a running one)
OWNER_KEY="$OWNER_KEY" PORT=3000 node server/src/server.js &
cd client && npm install
OWNER_KEY="$OWNER_KEY" AWS_REGION=ap-southeast-2 node bedrock-cli.js http://127.0.0.1:3000/mcp \
"Dim the living room light to 50%, then unlock the front door, then show me the audit log." \
| tee bedrock-transcript.txtThe transcript goes to stdout and the Confirm/Decline prompts to stderr, so the prompts
still show in the terminal while bedrock-transcript.txt holds only the transcript. Every
run makes a few real, metered Converse calls. client/test/bedrock-cli.test.js drives it
against the real server with only the Bedrock client mocked, through the same terminal
wiring on in-memory streams.
Inspecting with an off-the-shelf MCP client
With the server running (npm start in one terminal), use the reference MCP Inspector
CLI from another terminal to list and call the tools directly — no custom client code
needed:
npx -y @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp --method tools/list
npx -y @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp --method tools/call --tool-name list_devices --tool-arg room=kitchen
npx -y @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp --method tools/call --tool-name get_device_state --tool-arg device_id=dev_thermostat_1The same commands work against the live deployment: replace http://127.0.0.1:3000/mcp
with http://16.176.3.215:3000/mcp. The deployed server is shared by everyone who calls
it, so stick to the read-only tools there. It is an MCP endpoint, not a web page, so opening it
in a browser returns HTTP 400 (Missing Mcp-Session-Id header): every MCP session starts
with an initialize POST, which the Inspector sends for you.
tools/list against a live server returns all eight tools:
- list_devices
- get_device_state
- check_automation_policy
- read_audit_log
- propose_action
- execute_action
- compose_scene
- execute_scene
TOTAL: 8Demo walkthrough
The demo script, as the simulated client runs it:
The page starts idle. The registry behind it holds five devices: living room light, bedroom lamp, thermostat, front door lock (locked), kitchen coffee maker plug.
The user asks: "Turn on the living room light and set it to 50% brightness, then check the thermostat."
The agent calls
list_devices(the device tiles fill in), reads the light's state, callscheck_automation_policy, thenpropose_actionto dim the light to 50%.The conversation stops on a "Confirmation needed" panel showing the proposal's expected outcome and rationale. Nothing has changed yet.
The person clicks Confirm; only now is a one-time token minted, with the owner key.
The agent calls
execute_actionwith that token. The light's new state (brightnesscomes back, its tile moves to 50%, and an audit entry records
actor: "user".
The agent checks the thermostat.
The user adds: "Also unlock the front door." The agent checks the policy and proposes it, and the same panel appears.
The person clicks Decline. The agent says the door stays locked and never calls
execute_actionfor it; the lock's tile still reads Locked.The agent reads the audit log: one entry, the light. Both outcomes of the same gate in one run.
Below is a real transcript captured by driving client/src/planner.js against a freshly
started server, confirming the first proposal and declining the second — the same code
path the browser UI uses, with the button clicks scripted instead of clicked. Abridged by
truncating long JSON payloads (marked …) and leaving out turns 3, 8 and 10 (two state
reads and a policy check); nothing is paraphrased.
[1] USER: Turn on the living room light and set it to 50% brightness, then check the thermostat.
[2] AGENT: Let me see what devices are in the home.
tool: list_devices args={}
-> {"devices":[{"device_id":"dev_living_room_light_1","name":"Living Room Overhead", …5 devices…]}
[4] AGENT: Checking home automation policy allows dimming this light.
tool: check_automation_policy
-> {"allowed":true,"rule":"no_restrictions","reason":""}
[5] AGENT: Proposing: dim Living Room Overhead to 50% brightness.
tool: propose_action args={"device_id":"dev_living_room_light_1","action":"set_brightness","params":{"brightness":50}}
-> {"proposal_id":"prop_e41e087e-…","status":"awaiting_approval","confirmation_token":null, …}
[6] SYSTEM confirm gate -> proposal prop_e41e087e-… (set_brightness)
person clicks: CONFIRM
[7] AGENT: Executing the confirmed action.
tool: execute_action args={…,"confirmation_id":"confirm_1084393f-…"}
-> {"device_id":"dev_living_room_light_1","new_state":{"power":"on","brightness":50,"color_temp_k":4000},"result":"success","audit_entry_id":"audit_5d2f9672-…"}
[9] USER: Also unlock the front door.
[11] AGENT: Proposing: unlock the Front Door Lock.
tool: propose_action args={"device_id":"dev_front_door_lock_1","action":"unlock","params":{}}
-> {"proposal_id":"prop_eae09d4d-…","expected_outcome":"Front Door Lock will have unlock applied.","status":"awaiting_approval", …}
[12] SYSTEM confirm gate -> proposal prop_eae09d4d-… (unlock)
person clicks: DECLINE
[13] AGENT: Understood — the Front Door Lock stays locked. Nothing changed.
[14] AGENT: Here is the audit log for everything that actually executed this session.
tool: read_audit_log args={}
-> {"entries":[{"entry_id":"audit_5d2f9672-…","device_id":"dev_living_room_light_1",
"action":"set_brightness","actor":"user","reason":"User approved via prop_e41e087e-…",
"result":"success","new_state":{"power":"on","brightness":50,"color_temp_k":4000}}]}Two things to notice. The declined unlock produced no audit entry — the log ends with exactly one — and the door stayed locked. And nothing between the proposal and the person's click changed any state.
The other half of the gate is what happens when a caller skips the person entirely:
first an execute_action with no token, then an approve call without the owner key.
Against the same running server:
BEFORE {"device_id":"dev_front_door_lock_1","state":{"locked":true,"battery_pct":82},"last_updated":"2026-09-08T08:00:00Z"}
REFUSAL isError=true
execute_action requires a confirmation_id minted by the person confirming a
pending proposal in the client. Call propose_action first and wait for their
decision — this call was refused, nothing changed.
APPROVE (no owner key) HTTP 401 {"error":"Only the home owner can approve or decline a proposal:
send the owner key as \"Authorization: Bearer <key>\". Nothing was minted or changed."}
AFTER {"device_id":"dev_front_door_lock_1","state":{"locked":true,"battery_pct":82},"last_updated":"2026-09-08T08:00:00Z"}
AUDIT entries=1Same state, same last_updated, same audit count. The refusal is a true no-op, not a
warning printed beside a change that happened anyway.
Known limitations
Stated plainly, because a submission that hides these is worse than one that names them.
Deployed, with latency that depends on where you are. Live at
http://16.176.3.215:3000/mcp, an AWS EC2 instance (t3.micro, Amazon Linux 2023,ap-southeast-2) — the region aus-east-1/ App Runner Organizations restriction on this AWS account ruled out. Verified with a realinitializehandshake:HTTP 200, correctprotocolVersion/capabilities/serverInfo. Measured end-to-end latency from a US-based test origin is 570–700 ms, over the Alexa+ track's 500 ms line — curl's own timing breakdown shows this is 100% network distance toap-southeast-2(TCP connect alone is ~290 ms, one full round trip), not server processing: the loopbacktools/callround-trip is still 0.62–1.17 ms, unchanged. A judge testing from within Australia/APAC would see this comfortably under 500 ms; a US/EU tester will see the same geography this measurement did. The endpoint is plain HTTP, not HTTPS. docs/deploy.md says what is deployed and keeps the App Runner artifacts and runbook that could not be used on this account. The public host enforces the owner key: on 7 October 2026 (IST) an approve call without a key answered401, and its tools list carries annotations (details in docs/deploy.md). So nobody who only has the URL can approve a proposal there; they can propose and read.One owner key, not user accounts. The owner routes check a single shared secret. There is no per-person identity, so the audit log's
actoris"user"for every confirmed action, whoever clicked. OAuth 2.1 with PKCE is not implemented (anauth.jsfor it was planned and deliberately deferred); the MCP endpoint itself requires no credentials, and a real Alexa+ add-on would need that flow before account linking.Not the Alexa+ MCP Toolkit. This is a self-hosted MCP server plus a simulated Alexa+ client. Every tool call in that client is a real call to the real server, but no Alexa device is in the loop.
All state is in memory. Devices, proposals and audit entries live for the life of the server process; restarting re-reads
server/data/devices.jsonand forgets everything else. Nothing is written back to disk. This is deliberate, not an unfinished persistence layer. On the shared deployed server, every caller sees the same registry and audit log.The default planner is scripted, not reasoning. It walks a fixed conversation so the demo and tests run identically with no LLM and no API key. The
PLANNER=bedrockplanner reasons for real, but it needs AWS credentials on the machine running the client, and the automated tests exercise it only against a mocked Bedrock client. No run of the full loop with a real model has been recorded yet (bedrock-cli.jsabove is the way to record one).execute_scenedoes not roll back. If a later action in a scene fails, the earlier ones stay applied; the tool stops and reports exactly how far it got. Its description says so.
Privacy and security notes
No personal data, anywhere. The five devices in
server/data/devices.jsonare fictional, in a fictional house. No real address, account, network identifier or person appears in any source file, test, or fixture.Two credentials, each held where it is used. The MCP tools and both planners read no API key, token or password. The first credential is
OWNER_KEY: the server reads it from its environment and compares it (as SHA-256 digests, withtimingSafeEqual) onapprove/rejectonly; the client's local page (orbedrock-cli.js, from its own environment) carries it for its own Confirm and Decline, andMcpHttpClientkeeps it in a private field that it sends only to those two routes, never on atools/call. It is never logged or written to disk. The second is AWS, underPLANNER=bedrock:client/server.js'sPOST /bedrock/converseresolves an AWS credential the normal Node way (local~/.aws/credentials/ env vars in dev, an IAM role if ever deployed) to call Bedrock. That credential is held only in that Node process's own environment, is never logged, written to disk, or echoed in any response, and — the property that actually matters here — is structurally unreachable from browser-served code: the browser only ever POSTs a Converse request and reads back its JSON result, the same origin, no credential in either direction./bedrock/conversehas no auth or rate limit of its own;client/server.jslistens on127.0.0.1by default and answers only aHostheader naming this machine (below), and because this route is a real, metered AWS call, that default matters — don't setHOSTto a public interface.No outbound network calls, with the same exception. Every tool resolves against the in-memory registry; the server contacts no device, vendor API, or third-party service. The browser's only external fetch is loading React from a CDN via the import map in
client/index.html. UnderPLANNER=bedrock,client/server.jsadditionally makes one outbound call per conversation turn to Amazon Bedrock — the one deliberate exception, and never anything else.Authorization is structural, not advisory.
ProposalStore.approve()is the only code that ever mints aconfirmation_token, and its only caller is thePOST /proposals/:id/approveroute, which requires the owner key. No MCP tool wraps it, so no agent tool call can reach it, and an HTTP caller without the key is refused with401. Tokens are single-use:markExecutednulls the token, so a replay is refused.Tokens are not echoed back.
GET /proposals/:idstripsconfirmation_tokenbefore responding — the token is returned exactly once, to the caller that minted it.Policy is re-checked at execute time, never trusted from an earlier
check_automation_policycall, so an approval cannot be used to smuggle through an action the policy would now deny.execute-refusals.test.jsproves it by switching the policy to deny between the approval and the execute call.The audit log is append-only. There is no delete or redact path in
audit.js;read_audit_logis its only reader.Host-header validation is off, deliberately. The SDK's DNS-rebinding protection defaults to rejecting any request whose
Hostis notlocalhost/127.0.0.1— true until this demo was actually deployed to a real address, at which point that check refused every request from its own public IP withHTTP 403 Invalid Host. Disabled viacreateMcpExpressApp({ host: '0.0.0.0' })inserver/src/server.js, the same tradeoff already made for/mcp's CORS below: the agent surface has no auth and no origin allowlist, so this one check wasn't real protection, just an accident of the SDK's localhost-only default. The owner routes are protected by the owner key instead.CORS: open on
/mcp, allowlisted on/proposals/*./mcpanswers any origin (Access-Control-Allow-Origin: *), because any MCP client may connect and the demo runs two processes on two ports./proposals/*echoesAccess-Control-Allow-Originonly for the origins inOWNER_ORIGINS, on top of the owner key.The client's static server serves only
client/, from a fixed extension allowlist, rejects any resolved path outside its own directory, and listens on127.0.0.1by default, since the page it serves carries the owner key. Binding to loopback does not stop DNS rebinding (a page on some other domain that re-resolves to127.0.0.1would load this page as its own origin), so every route also answers403unless theHostheader is127.0.0.1,localhost,[::1]orHOST, on the port it listens on.client/test/server-config.test.jschecks this.
License
MIT — see LICENSE.
Documentation
Document | What it is |
Architecture diagrams (Mermaid + exported SVG), the approval-gate sequence, the refusal matrix, and how to onboard this server to Alexa+. | |
The Devpost write-up: text description, Built With, every form field, and the hackathon's submission checklist answered line by line with evidence. | |
What is deployed (EC2), how to redeploy it with the owner key, an HTTPS option, and the App Runner container build and runbook. | |
Product feedback on every tool, API and SDK used, including Amazon Bedrock, its AWS SDK client, AWS EC2 and App Runner. | |
Seven friction-log entries in the hackathon's requested format, three of them on AWS. | |
Demo video script, shot list, and timing budget. |
This server cannot be deployed
Maintenance
Related MCP Connectors
Human-in-the-loop review and approval for AI agents. Audit trail, approval policies, native MCP.
Zero-secret MCP gateway for AI agents: risk-scored, audited calls with human-in-the-loop approval.
Human-in-the-loop for AI agents over MCP: durable approvals with a hosted review page & audit trail
Deterministic IT asset registry operated by AI agents over MCP. Agents propose, humans approve.
Related MCP Servers
- FlicenseBqualityNot gradedmaintenanceEnables control and monitoring of Home Assistant smart home devices through MCP, allowing users to list entities, check device states, and call services to control lights, switches, sensors, and other connected devices.4-
- AlicenseNot gradedqualityBmaintenanceCentral MCP gateway for smart home automation, enabling agents to safely control Home Assistant and Node-RED with identity-based access, human confirmation for writes, and a WebUI for governance.1MIT
- AlicenseBqualityBmaintenanceProvides a unified MCP interface for smart home automation, enabling device discovery, state management, energy optimization, and policy-aware plan validation across multiple home automation protocols.20Mozilla Public 2.0
- AlicenseNot gradedqualityCmaintenanceEnables MCP-compatible AI clients to safely control Home Assistant devices and query MQTT sensors through natural language, using allowlist-guarded actions and pre-defined automations.MIT