Development
This page is for people working on hive-mcp itself. Start with CONTRIBUTING.md for the workflow, and use this page for how the code fits together.
You need Go 1.25 or later, and Docker for building the image.
git clone https://github.com/mattoddie/hive-mcp.gitcd hive-mcpmake test # runs all tests against a mock Hive| Target | What it does |
|---|---|
make build |
Build a static binary to bin/hive-mcp. Pass VERSION=1.2.3 to stamp a version. |
make test |
go test ./... |
make lint |
Fail if any file needs gofmt, then run go vet ./... |
make docs |
Regenerate docs/tools.md from the tool definitions |
make docs-check |
Fail if docs/tools.md is out of date (CI runs this) |
make image |
Build the Docker image as hive-mcp:$(VERSION) |
The project deliberately has only two direct dependencies: the official MCP Go SDK, github.com/modelcontextprotocol/go-sdk, and github.com/google/jsonschema-go, which the SDK uses for input schemas. Everything else, including the Cognito SRP login, is the standard library. Please discuss before adding a dependency.
Project layout
Section titled “Project layout”cmd/├── hive-mcp/ # the binary: serve, login, logout, status, healthcheck and version commands└── gen-tool-docs/ # writes docs/tools.md from the registered tools and promptsinternal/├── config/ # environment variables → typed Config (validation lives here)├── hive/│ ├── srp.go # Cognito's SRP-6a: user and device password claims, device verifiers│ ├── cognito.go # the few Cognito calls the login needs, as plain JSON over HTTP; reading Hive's sign-in page│ ├── auth.go # login with SMS code, remembered device, refresh, device re-login, the token file│ ├── client.go # Client: ID token auth, refresh on 401/403, retries on 429/503, the /nodes/all cache│ ├── format.go # helpers to read Hive's objects, schedules, and compact summaries│ ├── testdata/ # SRP test vectors generated with pyhiveapi's own code│ └── hivetest/ # an in-process fake Hive: sign-in page, Cognito (with real SRP checks) and the API├── httpserver/ # stateless Streamable HTTP, bearer auth, Host allow-list, /health└── tools/ ├── tools.go # the registration framework: Def, Gate, add[In](), schemas with enums and ranges ├── server.go # NewServer(): server instructions, registers every toolset and the prompts ├── helpers.go # finding zones by name, temperature validation, dates, sending a change ├── prompts.go # the built-in MCP prompts └── <toolset>.go # one file per toolset: home, heating, hotwater, devices, rawHow a request flows
Section titled “How a request flows”- An MCP client calls a tool, for example
hive_boost_heating {zone: "Heating", temperature: 21, minutes: 60}. - The MCP SDK validates the arguments against the tool’s JSON Schema, which was inferred from its Go input struct (with enums and minimum/maximum from the
Def), and decodes them into that struct. - The handler registered by
add()runs. It validates further (steps of 0.5 °C), reads the home withClient.Nodes(), which may come from the short cache, and finds the zone by name. change()sendsPOST /nodes/heating/<id>with{"mode":"BOOST","boost":60,"target":21}, which clears the cache, then reads the home again so the result shows the new state.hive.Clientgets an ID token fromAuth(refreshing it first if it’s about to expire), sends it as the bareAuthorizationheader, and unwraps the JSON. On401or403it refreshes once and retries. On429,502or503it waits and retries up to three times. Other failures become ahive.Errorwith Hive’s message and a hint.add()serialises the value to indented JSON text. A returned error becomesisError: truewith the message, so the model can see what went wrong and adjust.
How login works in the code
Section titled “How login works in the code”Hive logs in through AWS Cognito. hive-mcp talks to Cognito directly with JSON over HTTP (X-Amz-Target: AWSCognitoIdentityProviderService.<Action>), because every call it needs is unauthenticated or takes the user’s access token in the body: no AWS SDK or AWS credentials are involved.
srp.goimplements the client side of Cognito’s SRP-6a exactly as amazon-cognito-identity-js and pyhiveapi do: the 3072-bit group,k = H(N, g),u = H(A, B),x = H(salt, H(<pool name><user>:<password>)), HKDF with “Caldera Derived Key”, and the HMAC claim over<pool name><user><secret block><timestamp>. Device authentication uses the same maths with the device group key, device key and device password.testdata/srp_vectors.jsonholds values computed with pyhiveapi’s own helpers, andsrp_test.gochecks this code produces identical signatures and verifiers.auth.goruns the flows:StartLogin:InitiateAuth(USER_SRP_AUTH, withDEVICE_KEYif there’s a remembered device), thenPASSWORD_VERIFIER. Cognito answers with tokens,DEVICE_SRP_AUTH(a remembered device:srpLogincontinues withDEVICE_SRP_AUTHandDEVICE_PASSWORD_VERIFIER) orSMS_MFA(the session is kept forCompleteLogin).CompleteLogin: answersSMS_MFA. When the result offersNewDeviceMetadata,registerDevicemakes a random device password and callsConfirmDevice(with its verifier and salt) andUpdateDeviceStatus(remembered).Refresh:REFRESH_TOKEN_AUTHwith the device key; if Cognito refuses the refresh token, it’s dropped andreloginlogs in as the remembered device.reloginnever starts a login that would send an SMS, and backs off for five minutes after a failure.- Everything is saved to the token file (atomically, mode
0600) before the new ID token is handed out, and a running server reloads the file when another process, such ashive-mcp login, changes it.
Adding a tool
Section titled “Adding a tool”Tools are defined with the generic add[In]() in the file for their toolset. Here’s a complete example from devices.go:
type setPlugArgs struct { Plug string `json:"plug,omitempty" jsonschema:"Smart plug, by name or ID (see hive_list_smart_devices). Can be left out when there's only one"` On bool `json:"on" jsonschema:"true to switch the plug on, false to switch it off"`}
add(c, Def{ Toolset: "devices", Name: "hive_set_plug", Title: "Switch a smart plug", Description: "Switch a Hive smart plug on or off. Check what's plugged in with the user before switching it off.", Gate: Write, Idempotent: true,}, func(ctx context.Context, a setPlugArgs) (any, error) { n, err := c.nodes(ctx, false) if err != nil { return nil, err } p, err := find(n.Products(hive.PlugTypes...), a.Plug, "smart plug") if err != nil { return nil, err } status := "OFF" if a.On { status = "ON" } return change(ctx, cl, p, Obj{"status": status}, plugSummary)})The input struct
Section titled “The input struct”The input struct is the tool’s argument schema:
- The
jsontag names the argument. Usesnake_case. omitemptymakes it optional. Without it, the argument is required.- The
jsonschematag is the argument’s description, which the model reads. Every argument needs one, and a test checks this. - Use pointers (
*int,*bool,*float64) for optional values where zero means something, so “not given” and “set to zero” differ. - Embedded structs, such as
rawArgs,freshArgsorzoneArgs, add their fields to the tool. - Tools without arguments take
NoArgs.
The Def
Section titled “The Def”| Field | Effect |
|---|---|
Toolset |
Required. The tool is only registered when this toolset is enabled. |
Name, Title, Description |
The tool’s name (hive_<verb>_<thing>), a short human title, and the description the model reads. |
Gate |
Read (the default, always registered) or Write (needs HIVE_ALLOW_WRITES). |
ReadOnly |
Overrides the readOnlyHint annotation, which defaults to Gate == Read. |
Destructive |
Sets the destructiveHint annotation for tools that aren’t read-only. |
Idempotent |
Repeating the call with the same arguments has no further effect. Sets idempotentHint. |
OpenWorld |
The tool reaches outside Hive’s API, such as sending a text message. Sets openWorldHint. |
Enums |
Restricts string arguments to fixed values, keyed by argument name. |
Ranges |
Sets the minimum and maximum of number arguments, keyed by argument name. Validate in the handler too. |
Helpers
Section titled “Helpers”| Helper | Use it for |
|---|---|
c.nodes(ctx, fresh) |
The whole home, from the cache unless fresh. |
n.Products(types...), n.Devices(types...), n.Device(id) |
Picking products (what you control) and devices (the hardware) out of a hive.Nodes. |
find(list, ref, what) |
Resolve a name or ID, with errors that list the choices. |
checkTemperature() |
5–32 °C in steps of 0.5. |
parseWhen() |
Dates and times in the home’s time zone. |
change() |
Send a state change and return what was sent and the new state. |
n.Summarize*(), n.ScheduleNow(), hive.FormatSchedule() |
Compact summaries and readable schedules. |
Checklist for a new tool
Section titled “Checklist for a new tool”- Put it in the right
internal/tools/<toolset>.go. If it needs a new toolset, add it toToolsetsininternal/config/config.go, describe it intoolsetInfoincmd/gen-tool-docs/main.go, and register it inNewServer(). - Set its
Gatecorrectly: anything that changes the home isWrite. - Validate every value before it reaches Hive, and say what’s allowed in the description.
- Return a compact summary and offer
raw: truefor the full object. Never return tokens or secrets. - Write descriptions for the model: what the tool does, when to use it, units, and side effects.
- Add tests in
internal/tools/<toolset>_test.gousing the mock. - Run
make docsand commit the updateddocs/tools.md. If it’s a notable feature, update the README table and CHANGELOG.
Tool design guidelines
Section titled “Tool design guidelines”- Fewer, broader tools beat many narrow ones. One light tool with optional brightness and colour temperature is easier for a model than a tool per setting.
- Never surprise. Changes are explicit and validated. Boosts are preferred to permanent changes. Nothing sends a text message unless the user asked to log in.
- Be gentle on Hive. Reads go through the cache; only use
freshwhere it matters, such as cancelling a boost. - Errors should teach. Return messages that tell the model, or the user, how to fix the call, such as the list of zone names.
- Mirror the Hive app. Hive’s API is undocumented, so send what the app and pyhiveapi send, one change per request.
Testing
Section titled “Testing”Tests use Go’s testing package against hivetest.Mock (internal/hive/hivetest), an httptest server that imitates:
- Hive’s sign-in page at
/sso/, naming a test Cognito pool and client. - Cognito at
/cognito/:InitiateAuth(USER_SRP_AUTH,REFRESH_TOKEN_AUTH),RespondToAuthChallenge(PASSWORD_VERIFIER,SMS_MFA,DEVICE_SRP_AUTH,DEVICE_PASSWORD_VERIFIER),ConfirmDevice,UpdateDeviceStatusandForgetDevice. It computes the server side of SRP independently (S = (A·v^u)^b) and checks every signature, so a mistake in the client’s maths fails the tests. The test account ishivetest.Username/hivetest.Password, and the SMS code ishivetest.SMSCode. Switches such asMFAandDeviceTracking, andExpireTokens(),ExpireRefreshTokens()andForgetDevices(), set up each login path.SMSSentandCognitorecord what happened. - The API at
/api, with a small home (hivetest.SampleNodes()): a heating zone and thermostat, hot water, a TRV zone with a low-battery valve, a plug, two bulbs, a door sensor and a discontinued keypad.POST /nodes/<type>/<id>changes its state much as Hive does (boosts remember the previous mode), and/holiday-modeworks too.On(method, pattern, handler)overrides a route, andRequests(),Last(method)andCount()show what was sent.
newEnv(t) in internal/tools/helpers_test.go starts the mock, a client already logged in to it and an in-memory MCP session with every tool enabled. env.obj() runs a tool and decodes its JSON (failing on a tool error), env.callErr() returns a tool’s error text, and env.lastBody() shows the last change sent. A typical test:
func TestBoostHeating(t *testing.T) { env := newEnv(t) env.obj("hive_boost_heating", map[string]any{"zone": "Heating", "temperature": 22, "minutes": 30}) if got := env.lastBody(); !strings.HasSuffix(got, `{"boost":30,"mode":"BOOST","target":22}`) { t.Errorf("sent %s", got) }}To test that a tool is gated, pass different options: newEnv(t, opts) or connect(t, env.client, opts) with a config.Tools, and check the tool isn’t listed with toolNames(). TestGatesControlWhichToolsExist already checks every tool against its Gate, and TestSchemasDescribeEveryArgument checks that every argument has a description.
Run one package or test with go test ./internal/tools -run TestBoostHeating -v.
Running against a real Hive account
Section titled “Running against a real Hive account”There’s no Hive sandbox, so anything you try acts on a real home. Keep writes off until you’re sure, and use a boost (which ends by itself) rather than changes that stick.
With the MCP Inspector:
make buildexport HIVE_USERNAME=you@example.com HIVE_PASSWORD=... HIVE_TOKEN_FILE=$PWD/tokens.json./bin/hive-mcp loginnpx @modelcontextprotocol/inspector ./bin/hive-mcpOr build the image and point Claude Code at it:
make imagedocker run --rm -it -v hive-dev:/data --env-file .env hive-mcp:0.0.0-dev logindocker run --rm -p 8080:8080 -v hive-dev:/data --env-file .env hive-mcp:0.0.0-devclaude mcp add --transport http hive-dev http://localhost:8080/mcp --header "Authorization: Bearer $MCP_AUTH_TOKEN"tokens.json and .env are in .gitignore. Keep it that way, and never paste real tokens, passwords, device IDs or addresses into issues, tests or fixtures. If you capture a real /nodes/all response to improve the mock, anonymise it first.
Docs that are generated
Section titled “Docs that are generated”docs/tools.md is generated by cmd/gen-tool-docs. The program builds the server with every tool enabled, records each tool’s toolset and gate as it’s registered, reads the tools and prompts back over MCP, and writes the reference. Don’t edit docs/tools.md by hand. Change the tool’s title, description or jsonschema tags and run make docs. CI runs make docs-check and fails if the committed file is stale.
The website
Section titled “The website”The site at hive-mcp.mattoddie.dev lives in website/. It has a hand-built landing page plus these docs rendered with Astro Starlight. It has its own package.json, so its dependencies never reach the server or the Docker image.
The Markdown in docs/ is the single source: the site copies it in at build time. It turns each page’s first # Heading into the page title, drops hand-written “Contents” lists, rewrites links between .md files to site URLs and converts GitHub alerts (> [!NOTE]) to Starlight asides. So write docs as normal GitHub Markdown, start each page with a # Title, and link between pages with relative .md links.
To preview the site locally:
cd websitenpm cinpm run dev