{"id":"5feedd9b-bb77-4217-96b5-0c945b799dec","slug":"emrikol-ecobee-mcp","kind":"mcp","name":"ecobee-mcp","summary":"MCP server for Ecobee thermostat control","owner":"emrikol","repoUrl":"https://github.com/emrikol/ecobee-mcp","license":"GPL-3.0","licenseTier":"strong-copyleft","trust":"community","stars":1,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-02-09T23:00:27.000Z","addedAt":"2026-10-14T05:16:09.469Z","logoUrl":"https://avatars.githubusercontent.com/u/3060583?v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["cloud-infra"],"tags":[],"runtime":"binary","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-14T23:01:19.982Z","sample":false,"url":"https://conduits.dev/tools/emrikol-ecobee-mcp","language":"TypeScript","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# ecobee-mcp\n\nAn MCP server for controlling [Ecobee](https://www.ecobee.com/) thermostats through clients that support MCP `2026-07-28`.\n\nBuilt with TypeScript, Node's built-in HTTP and compression modules, and a\nperformance-focused fork of the official split v2 TypeScript SDK. The\n`@modelcontextprotocol/core`, `@modelcontextprotocol/server`, and\n`@modelcontextprotocol/node` packages are the only direct production\ndependencies.\n\nThe pinned SDK packages are built from\n[`emrikol/typescript-sdk-mod-performance`](https://github.com/emrikol/typescript-sdk-mod-performance)\ncommit `b7608a8ebbd19c33089f2b616b80df7592c84fba`. Their committed tarballs,\nchecksums, license, and rebuild instructions are documented in\n[vendor/README.md](vendor/README.md).\n\nVersion 2 is forward-only: it uses `server/discover`, advertises only MCP `2026-07-28`, and rejects the legacy `initialize` handshake. The HTTP endpoint remains `/mcp`.\n\n## Features\n\n**24 built-in tools** covering the supported Ecobee operations. Every tool has\na strict, bounded input schema, a bounded output schema, and an explicit safety\nannotation.\n\n| Tool                     | Class    | Operation                          |\n| ------------------------ | -------- | ---------------------------------- |\n| `list_thermostats`       | Read     | List registered thermostats        |\n| `get_thermostat_status`  | Read     | Read thermostat and equipment      |\n| `get_sensors`            | Read     | Read remote sensors                |\n| `get_weather`            | Read     | Read weather and forecast          |\n| `get_schedule`           | Read     | Read climates and weekly program   |\n| `list_vacations`         | Read     | List vacation events               |\n| `get_alerts`             | Read     | Read active alerts                 |\n| `get_runtime_report`     | Read     | Read interval runtime data         |\n| `get_extended_runtime`   | Read     | Read extended runtime data         |\n| `get_demand_response`    | Read     | Read demand-response events        |\n| `get_utility_info`       | Read     | Read utility metadata              |\n| `get_technician_info`    | Read     | Read technician metadata           |\n| `get_house_details`      | Read     | Read house characteristics         |\n| `list_groups`            | Read     | List thermostat groups             |\n| `set_temperature`        | Mutation | Set a temperature hold             |\n| `set_hvac_mode`          | Mutation | Change the HVAC mode               |\n| `set_hold`               | Mutation | Set a comfort-profile hold         |\n| `resume_schedule`        | Mutation | Resume the programmed schedule     |\n| `set_vacation`           | Mutation | Create, update, or delete vacation |\n| `acknowledge_alert`      | Mutation | Acknowledge an alert               |\n| `send_message`           | Mutation | Send a thermostat message          |\n| `update_comfort_profile` | Mutation | Update a comfort setting           |\n| `update_house_details`   | Mutation | Update house characteristics       |\n| `manage_group`           | Mutation | Create, update, or delete a group  |\n\nRead tools advertise `readOnlyHint: true`. Mutation tools advertise\n`readOnlyHint: false`, remain separate from reads, and return structured target,\nrequested-change, and reconciliation details. A mutation is never retried when\ndelivery is ambiguous.\n\n**3 resources** for quick context:\n\n- `ecobee://thermostat/status` — current thermostat state\n- `ecobee://thermostat/sensors` — remote sensor readings\n- `ecobee://thermostat/weather` — weather forecast data\n\n**Other:**\n\n- Bearer token authentication\n- Two auth modes: `readonly` (another app manages tokens) or `full` (manages its own OAuth refresh)\n- Plugin system for custom credential providers and atomically reloadable tools\n- 60-second read cache with in-flight request deduplication\n- Bounded deadlines, concurrency, rate-limit retries, and response sizes\n- Selective built-in gzip compression for large discovery and read responses\n- Development-only CPU, allocation, trace-event, and flamegraph tooling\n\n## Ecobee API Access\n\n**Ecobee is not currently accepting new developer registrations.** See the\n[official Developer API page](https://www.ecobee.com/developers/). If you do\nnot already have an Ecobee application key, you cannot create one through a\nnew developer registration.\n\nIf you already have an **Ecobee application key**, this server works directly\nwith your credentials. If not, you have a couple of options:\n\n1. **Piggyback on an existing integration.** If you already run an app that authenticates with the Ecobee API (e.g., a smart home platform), you can point ecobee-mcp at that app's credentials in `readonly` mode. ecobee-mcp will never refresh tokens itself — it just reads them and re-reads on 401 in case the other app has refreshed. See [Auth Modes](#auth-modes) below.\n\n2. **Write a credential provider plugin.** If the existing app stores tokens somewhere other than a plain JSON file (e.g., a database), write a plugin that implements the `CredentialProvider` interface to read from that source. See [Plugins](#plugins) below.\n\nThis project can work alongside another authenticated Ecobee application that manages the full OAuth lifecycle. In `readonly` mode, ecobee-mcp only consumes credentials from the configured provider.\n\n## Prerequisites\n\n- Node.js 20+\n- An Ecobee API key (see [Ecobee API Access](#ecobee-api-access))\n- OAuth tokens for your Ecobee account (access token, refresh token)\n\n## Setup\n\n### 1. Install dependencies\n\n```bash\nnpm install\n```\n\n### 2. Create a credentials file\n\nCreate `credentials.json` in the project root (this file is gitignored):\n\n```json\n{\n  \"accessToken\": \"your-ecobee-access-token\",\n  \"refreshToken\": \"your-ecobee-refresh-token\",\n  \"expiresAt\": 1700000000000,\n  \"apiKey\": \"your-ecobee-api-key\"\n}\n```\n\nIf another app manages your Ecobee tokens, point `CREDENTIALS_PATH` at that app's token file, or write a plugin to read from its storage.\n\n### 3. Configure environment\n\nCreate a `.env` file (also gitignored):\n\n```bash\nPORT=3000\nMCP_AUTH_TOKEN=some-random-secret-token\nCREDENTIALS_PATH=./credentials.json\nAUTH_MODE=readonly\n# MCP_PERFORMANCE_CACHES=0\n# ENABLE_PLUGINS=1\n```\n\n`MCP_AUTH_TOKEN` is strongly recommended on any network-accessible deployment.\nSDK performance caches default to enabled. Set `MCP_PERFORMANCE_CACHES=0` only\nfor a measured hard-memory constraint; it lowers allocator growth under heavy\ntraffic but materially reduces tool-call throughput.\n\n`ENABLE_PLUGINS=1` enables plugin loading and the operator-controlled tool\ncatalog reload boundary. It is disabled by default.\n\n### 4. Run\n\n```bash\n# Development (with hot reload)\nnpm run dev\n\n# Production\nnpm run build\nnpm start\n```\n\nThe server listens on `http://0.0.0.0:3000/mcp` with a health check at `/health`.\n\n## MCP Client Configuration\n\nPoint a modern MCP client at `/mcp`, send the configured bearer token, and explicitly enable the `2026-07-28` negotiation flow. The official TypeScript v2 client configuration is:\n\n```typescript\nimport {\n  Client,\n  StreamableHTTPClientTransport,\n} from \"@modelcontextprotocol/client\";\n\nconst client = new Client(\n  { name: \"my-client\", version: \"1.0.0\" },\n  { versionNegotiation: { mode: { pin: \"2026-07-28\" } } },\n);\nconst transport = new StreamableHTTPClientTransport(\n  new URL(\"http://localhost:3000/mcp\"),\n  {\n    requestInit: {\n      headers: { Authorization: \"Bearer your-auth-token-here\" },\n    },\n  },\n);\nawait client.connect(transport);\n\nif (client.getServerCapabilities()?.tools?.listChanged) {\n  client.setNotificationHandler(\n    \"notifications/tools/list_changed\",\n    async () => {\n      const latest = await client.listTools();\n      console.log(\"Updated catalog\", latest.tools);\n    },\n  );\n  await client.listen({ toolsListChanged: true });\n}\n```\n\nThe v2 client still defaults to its legacy connection flow unless `versionNegotiation` is configured. A client that cannot perform `server/discover` and send the modern per-request metadata cannot consume this endpoint.\n\nThe server advertises only tool and resource capabilities. It intentionally\ndoes not advertise prompts, sampling, elicitation, logging, experimental\nextensions, or Tasks. When plugin catalog reload is enabled, the tool\ncapability advertises `listChanged: true` and modern clients can receive change\nnotifications through `subscriptions/listen`; otherwise `listChanged` remains\nfalse.\n\nThis is a forward-facing version boundary. There is no legacy handshake,\nprotocol alias, or dual-stack compatibility path.\n\n## Auth Modes\n\n- **`readonly`** (default) — Reads tokens from the credentials file but never refreshes them. Use this when another application manages the OAuth lifecycle. On 401, it re-reads the credentials file in case the other app has refreshed the token. This is the recommended mode when piggybacking on another integration's credentials.\n- **`full`** — Manages the full OAuth token lifecycle, proactively refreshing tokens before expiry and persisting new credentials. Use this only if you have your own API key and want ecobee-mcp to handle token refresh independently.\n\n## Plugins\n\nPlugins are opt-in (`ENABLE_PLUGINS=1`) and loaded from the `plugins/` directory. A plugin can:\n\n- Provide a custom credential provider (read tokens from any source)\n- Register additional MCP tools and resources\n- Hook into token refresh events\n\n### Plugin interface\n\nA plugin is a `.js` file in the `plugins/` directory that exports an `EcobeePlugin` object:\n\n```typescript\ninterface EcobeePlugin {\n  name: string;\n  credentialProvider?: CredentialProvider;\n  onTokenRefresh?: (creds: EcobeeCredentials) => Promise<void>;\n  registerTools?: (\n    catalog: ToolCatalogRegistrar,\n    api: EcobeeApiClient,\n    cache: EcobeeCache,\n  ) => void;\n  registerResources?: (server: McpServer, cache: EcobeeCache) => void;\n}\n```\n\n`ToolCatalogRegistrar` deliberately exposes only `registerTool`. Every plugin\ntool must provide a unique MCP-safe name, bounded object input and output JSON\nSchemas, and an explicit boolean `annotations.readOnlyHint`. Plugin schemas are\ncompiled with the SDK before publication. A malformed plugin, invalid schema,\nor name collision rejects the complete candidate.\n\n### Reloading the tool catalog\n\nWith `ENABLE_PLUGINS=1`, replace the plugin files and send the service one\n`SIGHUP`:\n\n```bash\nsudo systemctl kill --signal=HUP --kill-whom=main ecobee-mcp\n```\n\nNormal MCP requests never trigger a filesystem scan or catalog reload. A\n`SIGHUP` loads and validates the complete candidate before atomically swapping\nthe shared snapshot. Requests already in progress retain their old snapshot;\nsubsequent requests receive the new generation. Failed candidates leave the\nlast-good generation active and emit no notification. A reload whose\ndeterministic fingerprint is unchanged still publishes refreshed handler code\nbut emits no notification because the client-visible catalog did not change.\n\nEach listed tool carries the accepted catalog SHA-256 in\n`_meta[\"io.github.emrikol/ecobee-mcp.catalogFingerprint\"]`. Clients that want\nchange notifications must open `subscriptions/listen` with\n`toolsListChanged: true`; one accepted catalog change produces one\n`notifications/tools/list_changed` notification.\n\nOnly plugin tools reload. Credential providers, token-refresh hooks, and\nplugin resources are process-lifetime configuration and still require a\nrestart. The normal deployment is one Node process and uses the SDK's in-memory\nevent bus. A multi-process embedding must pass one shared `ServerEventBus` to\n`createHttpService`; do not run independent in-memory buses behind one\nendpoint.\n\nSuperseded catalog snapshots are collectible after their in-flight requests\nfinish. Node's ESM loader retains evaluated module records, however, so a\nservice that cycles through many distinct plugin code versions should receive\na planned restart to reclaim that module-loader memory.\n\n### Example: Custom credential provider\n\nIf your existing Ecobee integration stores tokens in a SQLite database:\n\n```javascript\n// plugins/my-creds.js\nimport Database from \"better-sqlite3\";\n\nconst db = new Database(\"/path/to/other-app/ecobee.db\");\n\nexport default {\n  name: \"my-credential-provider\",\n  credentialProvider: {\n    async getCredentials() {\n      const row = db.prepare(\"SELECT * FROM ecobee_tokens LIMIT 1\").get();\n      return {\n        accessToken: row.access_token,\n        refreshToken: row.refresh_token,\n        expiresAt: row.expires_at,\n        apiKey: row.api_key,\n      };\n    },\n    async saveCredentials() {\n      // No-op — the other app manages token persistence\n    },\n  },\n};\n```\n\nThe first plugin to provide a `credentialProvider` wins. Subsequent credential providers are ignored with a warning.\n\n## Deployment\n\nThe `scripts/` directory contains shell scripts for deploying to a Linux server (e.g., a Raspberry Pi) via SSH + systemd. These are tailored to my personal setup — see the comments in each script for what to adapt.\n\n## Development\n\n```bash\nnpm test                 # Run tests\nnpm run test:watch       # Watch mode\nnpm run build            # Type-check and compile production code\nnpm run typecheck:bench  # Type-check the benchmark harness\nnpm run lint             # Lint production, test, and benchmark code\nnpm run format:check     # Check formatting\nnpm run benchmark        # Run deterministic local benchmarks\nnpm run profile          # Capture CPU and allocation profiles\nnpm run profile:analyze  # Build analysis JSON and an SVG flamegraph\nnpm run profile:trace    # Capture Node trace events plus profiles\n```\n\nThe benchmark uses an injected fake Ecobee transport. It never calls the live\nEcobee API and never performs a thermostat mutation. Generated profiles live in\nthe gitignored `.artifacts/performance/` directory.\n\nSee [Performance](docs/performance.md) for methodology, before/after results,\nknown costs, the measured Raspberry Pi memory footprint, and profiling\ncommands. See\n[Development diagnostics](docs/observability.md) for local trace and profile\nartifacts. Production contains no telemetry instrumentation or exporter.\n\nThe current Node deployment is an intentional choice, not an unfinished port.\nIts steady post-read footprint is approximately 79 MiB RSS on the 8 GiB Pi,\nwhich has ample available memory; V8 memory-saving flags caused material\nthroughput regressions. [Future work](TODO.md) records the measured triggers,\nparity plan, and acceptance criteria for reconsidering a Rust implementation.\n\n## Support Policy\n\nThis project is published **as-is** under the [GPL-3.0 license](LICENSE).\n\n- **No support** is provided — no bug fixes, feature requests, or troubleshooting.\n- **Issues are disabled.** The issue tracker is not monitored.\n- **Pull requests are collaborator-only.** Non-collaborator PRs are automatically closed.\n- You are welcome to **fork** this project and adapt it for your own use under the GPL.\n\nIf you redistribute a modified version, please use a different project name and branding to avoid confusion.\n\n## License\n\n[GPL-3.0](LICENSE)\n"}