Grok in a Tesla can now use connectors, but it can't sign in to Google Home on its own. This guide sets up a small, free bridge so you can say "turn off the backyard lights" or "set the house to 70" from the driver's seat and have it actually happen.
I built this for my own house, and the bridge is tested end to end on Cloudflare and Google. I hit most of the problems in the troubleshooting section along the way, so you don't have to. You'll deploy your own copy of the bridge, so your Google sign-in never passes through anyone else's server, including mine.
Why Grok can't connect to Google Home directly
Google offers a Google Home MCP server, an official way for AI assistants to control your devices. The catch is how it signs in: Google requires every assistant to bring its own pre-registered sign-in credentials.
Grok's custom connector form only asks for a name and a server URL. There's nowhere to enter those credentials, so if you point Grok straight at Google's server it says "added" and then shows no tools.
The bridge fixes that. Grok connects to the bridge using a sign-in method Grok supports, and the bridge holds your Google credentials and talks to Google Home for you. Because connectors you add in the Grok app carry over to the car once you're signed in, setting it up once makes it work in your Tesla too.
What you need
| Requirement | Notes |
|---|---|
| Google Home Premium Advanced | Google requires it for its Home MCP server, which is in early access and rolling out in the US first. |
| Grok with Connectors | Add connectors in the Grok app or at grok.com. Which connector features you get can depend on your Grok plan. |
| A Tesla that runs Grok | Grok in the car needs an AMD infotainment computer, software 2025.26 or later, and Premium Connectivity. Skip this if you only want Grok on your phone. |
| A free Cloudflare account | The bridge runs here. The free plan is plenty for one household. |
| A Google Cloud project | Free. You'll create it in Step 3. |
Have a password manager open. You'll choose one passphrase and copy two long values from Google. Paste them; don't type them. A single mistyped character produces errors that don't say which field is wrong.
Setup, step by step
Cloudflare and Google update their dashboards often. If a button is worded slightly differently from what you see here, look for the closest match.
1Create the bridge in Cloudflare
/*
* Google Home MCP Bridge v1.1.0
* Lets Grok (including Grok in a Tesla) control Google Home through Google's Home MCP server.
*
* Setup guide: https://fullyautomatedthings.com/guides/grok-google-home/
*
* Paste all of this code into your Cloudflare Worker's code editor, replacing everything there.
* It needs a KV namespace bound with the variable name OAUTH_KV, and these secrets:
* PROXY_PASSPHRASE (16+ characters), GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET.
*
* NOT A SECURITY OR SAFETY SYSTEM. Do not rely on it to secure your home or to protect
* people, pets, or property. You are responsible for your own accounts and devices.
*
* MIT License
*
* Copyright (c) 2026 Fully Automated Things
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
var __defProp = Object.defineProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
// src/google.js
var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
var HOME_MCP_URL = "https://home.googleapis.com/mcp";
var HOME_SCOPE = "https://www.googleapis.com/auth/home.platform.v2";
var KV_REFRESH = "google:refresh_token";
var KV_REFRESH_AT = "google:refresh_token_obtained_at";
var KV_ACCESS = "google:access_token";
var KV_INVENTORY = "google:inventory";
var INVENTORY_TTL = 300;
var EXCLUDED_TYPES = /* @__PURE__ */ new Set([
"GoogleCameraDevice",
"GoogleDoorbellDevice"
]);
var FORBIDDEN_COMMANDS = /* @__PURE__ */ new Set([
"DoorLock.UnlockDoor",
"DoorLock.WriteAttributes"
]);
var LOCK_ALLOWED_COMMANDS = /* @__PURE__ */ new Set(["DoorLock.LockDoor"]);
function assertPermitted(r) {
if (FORBIDDEN_COMMANDS.has(r.command)) {
throw new Error(`Refusing to send ${r.command}: this bridge is lock-only by design.`);
}
const touchesLock = String(r.command).startsWith("DoorLock.") || r.type === "DoorLockDevice";
if (touchesLock && !LOCK_ALLOWED_COMMANDS.has(r.command)) {
throw new Error(
`Refusing to send ${r.command} to a door lock: this bridge is lock-only by design and permits no other lock command.`
);
}
}
__name(assertPermitted, "assertPermitted");
var secret = /* @__PURE__ */ __name((v) => (typeof v === "string" ? v.trim() : v) || null, "secret");
var NeedsGoogleAuth = class extends Error {
static {
__name(this, "NeedsGoogleAuth");
}
constructor(msg = "Google authorization is missing or expired.") {
super(msg);
this.name = "NeedsGoogleAuth";
}
};
async function readRefreshToken(env) {
return secret(await env.OAUTH_KV.get(KV_REFRESH)) || secret(env.GOOGLE_REFRESH_TOKEN) || null;
}
__name(readRefreshToken, "readRefreshToken");
async function storeRefreshToken(env, token) {
await env.OAUTH_KV.put(KV_REFRESH, token);
await env.OAUTH_KV.put(KV_REFRESH_AT, (/* @__PURE__ */ new Date()).toISOString());
await env.OAUTH_KV.delete(KV_ACCESS);
await env.OAUTH_KV.delete(KV_INVENTORY);
}
__name(storeRefreshToken, "storeRefreshToken");
async function getAccessToken(env, { forceRefresh = false } = {}) {
if (!forceRefresh) {
const cached = await env.OAUTH_KV.get(KV_ACCESS);
if (cached) return cached;
}
const refresh = await readRefreshToken(env);
if (!refresh) throw new NeedsGoogleAuth("No Google refresh token is stored.");
const clientId = secret(env.GOOGLE_CLIENT_ID);
const clientSecret = secret(env.GOOGLE_CLIENT_SECRET);
if (!clientId || !clientSecret) {
throw new NeedsGoogleAuth("GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET are not configured.");
}
const res = await fetch(GOOGLE_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refresh,
client_id: clientId,
client_secret: clientSecret
})
});
const body = await res.json().catch(() => ({}));
if (!res.ok || !body.access_token) {
if (body.error === "invalid_grant") {
await env.OAUTH_KV.delete(KV_REFRESH);
throw new NeedsGoogleAuth(
"The stored Google refresh token has expired or been revoked. Re-authorize at /google/start."
);
}
throw new Error(`Google token exchange failed (${res.status}): ${body.error || "unknown"}`);
}
const ttl = Math.max(60, (body.expires_in || 3600) - 300);
await env.OAUTH_KV.put(KV_ACCESS, body.access_token, { expirationTtl: ttl });
return body.access_token;
}
__name(getAccessToken, "getAccessToken");
async function exchangeCodeForRefreshToken(env, code, redirectUri) {
const res = await fetch(GOOGLE_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: secret(env.GOOGLE_CLIENT_ID),
client_secret: secret(env.GOOGLE_CLIENT_SECRET),
redirect_uri: redirectUri
})
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(`${body.error || res.status}: ${body.error_description || "exchange failed"}`);
}
if (!body.refresh_token) {
throw new Error(
"Google returned no refresh token. A grant already exists \u2014 revoke this app at myaccount.google.com/permissions and try again."
);
}
return body.refresh_token;
}
__name(exchangeCodeForRefreshToken, "exchangeCodeForRefreshToken");
async function authStatus(env) {
const refresh = await readRefreshToken(env);
const obtainedAt = await env.OAUTH_KV.get(KV_REFRESH_AT);
const source = await env.OAUTH_KV.get(KV_REFRESH) ? "kv (re-auth flow)" : secret(env.GOOGLE_REFRESH_TOKEN) ? "worker secret" : "none";
let ageDays = null;
if (obtainedAt) {
ageDays = Math.floor((Date.now() - Date.parse(obtainedAt)) / 864e5);
}
return {
hasRefreshToken: !!refresh,
hasClientCredentials: !!(secret(env.GOOGLE_CLIENT_ID) && secret(env.GOOGLE_CLIENT_SECRET)),
// The client id is NOT a secret - it is sent in the clear on every auth request - so
// echoing a fingerprint of it is safe and is the fastest way to spot a bad paste.
clientIdLength: (secret(env.GOOGLE_CLIENT_ID) || "").length,
clientIdTail: (secret(env.GOOGLE_CLIENT_ID) || "").slice(-18) || null,
rawClientIdHadWhitespace: typeof env.GOOGLE_CLIENT_ID === "string" && env.GOOGLE_CLIENT_ID !== env.GOOGLE_CLIENT_ID.trim(),
rawSecretHadWhitespace: typeof env.GOOGLE_CLIENT_SECRET === "string" && env.GOOGLE_CLIENT_SECRET !== env.GOOGLE_CLIENT_SECRET.trim(),
source,
obtainedAt: obtainedAt || null,
ageDays
};
}
__name(authStatus, "authStatus");
async function homeTool(env, name, args = {}, { retry = true } = {}) {
const token = await getAccessToken(env);
const res = await fetch(HOME_MCP_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
jsonrpc: "2.0",
id: Date.now(),
method: "tools/call",
params: { name, arguments: args }
})
});
if (res.status === 401 && retry) {
await env.OAUTH_KV.delete(KV_ACCESS);
await getAccessToken(env, { forceRefresh: true });
return homeTool(env, name, args, { retry: false });
}
if (!res.ok) {
throw new Error(`Home API ${name} failed with HTTP ${res.status}`);
}
const body = await res.json();
if (body.error) throw new Error(`Home API ${name}: ${body.error.message || "error"}`);
const text2 = body.result?.content?.[0]?.text;
if (text2) {
try {
return JSON.parse(text2);
} catch {
return { raw: text2 };
}
}
return body.result ?? {};
}
__name(homeTool, "homeTool");
var normalize = /* @__PURE__ */ __name((s) => String(s || "").toLowerCase().replace(/[^a-z0-9 ]/g, " ").replace(/\s+/g, " ").trim(), "normalize");
var CATEGORIES = {
light: /* @__PURE__ */ __name((d) => d.type === "GoogleLightDevice" || /light|lamp|bulb/i.test(d.name), "light"),
lamp: /* @__PURE__ */ __name((d) => CATEGORIES.light(d), "lamp"),
bulb: /* @__PURE__ */ __name((d) => CATEGORIES.light(d), "bulb"),
plug: /* @__PURE__ */ __name((d) => d.type === "GooglePluginUnitDevice" || /plug|outlet/i.test(d.name), "plug"),
outlet: /* @__PURE__ */ __name((d) => CATEGORIES.plug(d), "outlet"),
switch: /* @__PURE__ */ __name((d) => d.canPower, "switch"),
speaker: /* @__PURE__ */ __name((d) => d.type === "SpeakerDevice" || /speaker/i.test(d.name), "speaker"),
display: /* @__PURE__ */ __name((d) => d.type === "GoogleDisplayDevice" || /display|hub/i.test(d.name), "display"),
tv: /* @__PURE__ */ __name((d) => d.type === "GoogleTvDevice" || /\btv\b/i.test(d.name), "tv"),
thermostat: /* @__PURE__ */ __name((d) => d.isThermostat, "thermostat")
};
var SINGULARS = {
lights: "light",
lamps: "lamp",
bulbs: "bulb",
plugs: "plug",
outlets: "outlet",
switches: "switch",
speakers: "speaker",
displays: "display",
tvs: "tv",
thermostats: "thermostat"
};
var depluralize = /* @__PURE__ */ __name((q) => q.split(" ").map((w) => SINGULARS[w] || w).join(" "), "depluralize");
async function getInventory(env, { fresh = false } = {}) {
if (!fresh) {
const cached = await env.OAUTH_KV.get(KV_INVENTORY, "json");
if (cached) return cached;
}
const homes = await homeTool(env, "list_homes", {});
const structures = (homes.structures || []).map((s) => ({
id: s.structureId,
name: s.displayName
}));
const devices = [];
const rooms = [];
for (const st of structures) {
const res = await homeTool(env, "list_home_resources", {
structureId: st.id,
view: "VIEW_SUMMARY"
});
const roomNames = /* @__PURE__ */ new Map();
for (const r of res.resources || []) {
if (r.type === "Room") roomNames.set(r.id, r.displayName);
}
for (const [id, name] of roomNames) {
rooms.push({ id, name, structureId: st.id, structureName: st.name });
}
for (const r of res.resources || []) {
if (r.type === "Room" || r.type === "Structure") continue;
if (EXCLUDED_TYPES.has(r.type)) continue;
const traits = r.supportedTraits || [];
devices.push({
id: r.id,
name: r.displayName,
type: r.type,
room: roomNames.get((r.parentIds || [])[0]) || null,
structureId: st.id,
structureName: st.name,
canPower: traits.includes("OnOff") || traits.includes("SimplifiedOnOff"),
canDim: traits.includes("Brightness"),
canColor: traits.includes("ExtendedColorControl"),
isThermostat: r.type === "ThermostatDevice",
// Locking only. There is deliberately no canUnlock anywhere in this codebase.
canLock: traits.includes("DoorLock")
});
}
}
const inv = { structures, rooms, devices, builtAt: (/* @__PURE__ */ new Date()).toISOString() };
await env.OAUTH_KV.put(KV_INVENTORY, JSON.stringify(inv), { expirationTtl: INVENTORY_TTL });
return inv;
}
__name(getInventory, "getInventory");
function matchDevices(inventory, query, { home = null, filter = null } = {}) {
const raw = normalize(query);
if (!raw) return { devices: [], reason: "no name given" };
const q = depluralize(raw);
let pool = inventory.devices;
if (filter) pool = pool.filter(filter);
if (home) {
const h = normalize(home);
pool = pool.filter((d) => normalize(d.structureName).includes(h));
}
const done = /* @__PURE__ */ __name((devices, how, label) => {
if (!devices.length) return null;
const homes = [...new Set(devices.map((d) => d.structureName))];
if (homes.length > 1) {
return { devices: [], ambiguousHomes: homes, reason: "matches more than one home" };
}
return {
devices,
how,
label,
names: [...new Set(devices.map((d) => d.name))]
};
}, "done");
const exact = pool.filter((d) => depluralize(normalize(d.name)) === q);
const byExact = done(exact, "exact name", exact[0]?.name);
if (byExact) return byExact;
const words = q.split(" ");
const roomExact = inventory.rooms.filter((r) => normalize(r.name) === q);
if (roomExact.length) {
const inRoom = pool.filter((d) => roomExact.some((r) => d.room === r.name && d.structureId === r.structureId));
const byRoom = done(inRoom, "room", `${roomExact[0].name} (whole room)`);
if (byRoom) return byRoom;
}
const partial = pool.filter((d) => {
const n = depluralize(normalize(d.name));
return words.every((w) => n.includes(w));
});
const byPartial = done(
partial,
"name match",
[...new Set(partial.map((d) => d.name))].join(" + ")
);
if (byPartial) return byPartial;
const catWords = words.filter((w) => CATEGORIES[w]);
if (catWords.length) {
const rest = words.filter((w) => !CATEGORIES[w]).join(" ");
const predicate = /* @__PURE__ */ __name((d) => catWords.some((w) => CATEGORIES[w](d)), "predicate");
if (rest) {
const room = inventory.rooms.find((r) => normalize(r.name) === rest) || inventory.rooms.find((r) => {
const n = normalize(r.name);
return rest.split(" ").every((w) => n.includes(w));
});
if (room) {
const inRoom = pool.filter((d) => d.room === room.name && d.structureId === room.structureId && predicate(d));
const byCat = done(inRoom, "room + category", `${room.name} ${catWords[0]}s`);
if (byCat) return byCat;
}
const named = pool.filter((d) => {
const n = depluralize(normalize(d.name));
return rest.split(" ").every((w) => n.includes(w)) && predicate(d);
});
const byNamed = done(
named,
"category + name",
[...new Set(named.map((d) => d.name))].join(" + ")
);
if (byNamed) return byNamed;
} else {
const all = pool.filter(predicate);
const byAll = done(all, "category", `all ${catWords[0]}s`);
if (byAll) return byAll;
}
}
return { devices: [], reason: "no match" };
}
__name(matchDevices, "matchDevices");
async function runActions(env, requests) {
for (const r of requests) assertPermitted(r);
const byStructure = /* @__PURE__ */ new Map();
for (const r of requests) {
if (!byStructure.has(r.structureId)) byStructure.set(r.structureId, []);
byStructure.get(r.structureId).push({
id: r.id,
type: r.type,
command: r.command,
...r.parameters ? { parameters: r.parameters } : {}
});
}
let count = 0;
for (const [structureId, homeActionRequests] of byStructure) {
await homeTool(env, "run_home_actions", { structureId, homeActionRequests });
count += homeActionRequests.length;
}
return count;
}
__name(runActions, "runActions");
async function readStates(env, structureId, resourceIds, traits) {
return homeTool(env, "list_home_states", {
structureId,
freshness: "MOST_FRESH",
filter: { resourceIds, shortenedResourceTraits: traits }
});
}
__name(readStates, "readStates");
var f2c = /* @__PURE__ */ __name((f) => (f - 32) * 5 / 9, "f2c");
var c2f = /* @__PURE__ */ __name((c) => c * 9 / 5 + 32, "c2f");
var MIN_F = 50;
var MAX_F = 90;
function clampF(f) {
return Math.min(MAX_F, Math.max(MIN_F, f));
}
__name(clampF, "clampF");
// src/tools.js
var text = /* @__PURE__ */ __name((s) => ({ content: [{ type: "text", text: s }] }), "text");
var fail = /* @__PURE__ */ __name((s) => ({ content: [{ type: "text", text: s }], isError: true }), "fail");
var TOOLS = [
{
name: "ping",
description: "Connectivity check. Confirms the smart home bridge is reachable and authorized.",
inputSchema: {
type: "object",
properties: { message: { type: "string", description: "Optional text echoed back." } }
}
},
{
name: "list_devices",
description: "List the controllable devices in the home, grouped by room. Use this to discover what exists and what a device is actually called before controlling it. Several devices may share one name; that is a group of bulbs and they are controlled together.",
inputSchema: {
type: "object",
properties: {
home: { type: "string", description: "Optional home name to limit results to." }
}
}
},
{
name: "set_power",
description: "Turn a device or group on or off. Works for lights, smart plugs and switches. If several devices share the given name they are all switched together.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: 'Device, group or room name, e.g. "kitchen light".' },
state: { type: "string", enum: ["on", "off", "toggle"], description: "Desired state." },
home: { type: "string", description: "Optional home name, if two homes have this device." }
},
required: ["name", "state"]
}
},
{
name: "set_brightness",
description: "Set how bright a light or light group is, as a percentage from 1 to 100. Only works on dimmable lights. Setting brightness does not turn a light on by itself.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Light or group name." },
percent: { type: "number", description: "Brightness from 1 to 100." },
home: { type: "string" }
},
required: ["name", "percent"]
}
},
{
name: "set_color",
description: 'Set the color of a color-capable light or group using a plain color word, such as "blue", "warm white" or "red".',
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Light or group name." },
color: { type: "string", description: 'A color name, e.g. "blue".' },
home: { type: "string" }
},
required: ["name", "color"]
}
},
{
name: "set_thermostat",
description: `Set the thermostat. Temperatures are in DEGREES FAHRENHEIT and are limited to ${MIN_F}-${MAX_F} F for safety. Give target_f to set a temperature, or mode to change between heat, cool, auto and off. Reads the current setting first, so "warmer" can be expressed as a target.`,
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Thermostat name. Optional if there is only one." },
target_f: { type: "number", description: `Target temperature in F (${MIN_F}-${MAX_F}).` },
mode: { type: "string", enum: ["heat", "cool", "auto", "off"], description: "Operating mode." },
home: { type: "string" }
}
}
},
{
name: "get_status",
description: "Read the current state of a device or group: whether it is on, its brightness, or for a thermostat the current temperature and target, reported in Fahrenheit.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Device, group or room name." },
home: { type: "string" }
},
required: ["name"]
}
},
{
name: "lock_door",
description: "Lock a door lock. This bridge can ONLY lock: there is no unlock capability of any kind, and unlocking must be done from the Google Home app, the keypad, or a key. Use get_status to read whether a lock is currently locked.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: 'Lock name, e.g. "front door". Optional if there is only one.' },
home: { type: "string" }
}
}
},
{
name: "auth_status",
description: "Report whether the bridge still holds a valid Google authorization, and how old it is. Use this to diagnose failures that look like missing devices.",
inputSchema: { type: "object", properties: {} }
}
];
function describeMatch(res, what) {
if (res.ambiguousHomes) {
return fail(
`"${what}" exists in more than one home (${res.ambiguousHomes.join(", ")}). Say which home you mean.`
);
}
return fail(`No controllable device matching "${what}". Use list_devices to see what exists.`);
}
__name(describeMatch, "describeMatch");
function plural(n, word) {
return `${n} ${word}${n === 1 ? "" : "s"}`;
}
__name(plural, "plural");
function affected(res) {
const n = res.devices.length;
if (n === 1) return res.devices[0].name;
const names = res.names || [];
if (names.length === 1) return `${names[0]} (${plural(n, "device")})`;
return `${plural(n, "device")}: ${names.join(", ")}`;
}
__name(affected, "affected");
async function callTool(name, args, env) {
try {
switch (name) {
case "ping": {
const echo = args?.message ? ` echo=${args.message}` : "";
return text(`pong from google-home-mcp-bridge at ${(/* @__PURE__ */ new Date()).toISOString()}${echo}`);
}
case "auth_status": {
const s = await authStatus(env);
if (!s.hasRefreshToken) {
return text(
"Not authorized to Google. No refresh token is stored. Visit /google/start on this server to authorize."
);
}
const age = s.ageDays === null ? "unknown age" : `obtained ${plural(s.ageDays, "day")} ago`;
return text(
`Authorized to Google. Token source: ${s.source}, ${age}. Client credentials configured: ${s.hasClientCredentials ? "yes" : "NO"}.`
);
}
case "list_devices": {
const inv = await getInventory(env);
const pool = args?.home ? inv.devices.filter((d) => d.structureName.toLowerCase().includes(String(args.home).toLowerCase())) : inv.devices;
if (!pool.length) return text("No controllable devices found.");
const byRoom = /* @__PURE__ */ new Map();
for (const d of pool) {
const key = `${d.structureName} / ${d.room || "No room"}`;
if (!byRoom.has(key)) byRoom.set(key, /* @__PURE__ */ new Map());
const g = byRoom.get(key);
if (!g.has(d.name)) g.set(d.name, { count: 0, caps: [] });
const entry = g.get(d.name);
entry.count++;
entry.caps = [
d.canDim ? "dimmable" : null,
d.canColor ? "color" : null,
d.isThermostat ? "thermostat" : null
].filter(Boolean);
}
const lines = [];
for (const [room, group] of [...byRoom].sort()) {
lines.push(room);
for (const [dname, info] of [...group].sort()) {
const bits = [];
if (info.count > 1) bits.push(`${info.count} bulbs, controlled together`);
bits.push(...info.caps);
lines.push(` - ${dname}${bits.length ? ` (${bits.join(", ")})` : ""}`);
}
}
return text(lines.join("\n"));
}
case "set_power": {
const state = String(args?.state || "").toLowerCase();
if (!["on", "off", "toggle"].includes(state)) {
return fail('state must be "on", "off" or "toggle".');
}
const inv = await getInventory(env);
const res = matchDevices(inv, args?.name, {
home: args?.home,
filter: /* @__PURE__ */ __name((d) => d.canPower, "filter")
});
if (!res.devices.length) return describeMatch(res, args?.name);
const command = state === "on" ? "OnOff.On" : state === "off" ? "OnOff.Off" : "OnOff.Toggle";
await runActions(env, res.devices.map((d) => ({
id: d.id,
type: d.type,
structureId: d.structureId,
command
})));
const verb = state === "toggle" ? "Toggled" : `Turned ${state}`;
return text(`${verb} ${affected(res)}.`);
}
case "set_brightness": {
const pct = Number(args?.percent);
if (!Number.isFinite(pct) || pct < 1 || pct > 100) {
return fail("percent must be a number from 1 to 100.");
}
const inv = await getInventory(env);
const res = matchDevices(inv, args?.name, {
home: args?.home,
filter: /* @__PURE__ */ __name((d) => d.canDim, "filter")
});
if (!res.devices.length) return describeMatch(res, args?.name);
await runActions(env, res.devices.map((d) => ({
id: d.id,
type: d.type,
structureId: d.structureId,
command: "Brightness.MoveToBrightness",
parameters: { brightnessPercent: Math.round(pct) }
})));
return text(`Set ${affected(res)} to ${Math.round(pct)}% brightness.`);
}
case "set_color": {
const color = String(args?.color || "").trim();
if (!color) return fail('color is required, e.g. "blue".');
const inv = await getInventory(env);
const res = matchDevices(inv, args?.name, {
home: args?.home,
filter: /* @__PURE__ */ __name((d) => d.canColor, "filter")
});
if (!res.devices.length) return describeMatch(res, args?.name);
await runActions(env, res.devices.map((d) => ({
id: d.id,
type: d.type,
structureId: d.structureId,
command: "ExtendedColorControl.MoveToColorName",
parameters: { colorName: color }
})));
return text(`Set ${affected(res)} to ${color}.`);
}
case "set_thermostat": {
const wantTemp = args?.target_f !== void 0 && args?.target_f !== null;
const wantMode = !!args?.mode;
if (!wantTemp && !wantMode) return fail("Give target_f, mode, or both.");
if (wantTemp && !Number.isFinite(Number(args.target_f))) {
return fail("target_f must be a number.");
}
const modeEnum = wantMode ? {
heat: "SYSTEM_MODE_ENUM_HEAT",
cool: "SYSTEM_MODE_ENUM_COOL",
auto: "SYSTEM_MODE_ENUM_AUTO",
off: "SYSTEM_MODE_ENUM_OFF"
}[String(args.mode).toLowerCase()] : null;
if (wantMode && !modeEnum) return fail("mode must be heat, cool, auto or off.");
const inv = await getInventory(env);
const thermostats = inv.devices.filter((d) => d.isThermostat);
if (!thermostats.length) return fail("No thermostat found in this home.");
let target;
if (args?.name) {
const res = matchDevices(inv, args.name, {
home: args?.home,
filter: /* @__PURE__ */ __name((d) => d.isThermostat, "filter")
});
if (!res.devices.length) return describeMatch(res, args.name);
target = res.devices[0];
} else if (thermostats.length === 1) {
target = thermostats[0];
} else {
return fail(
`There are ${thermostats.length} thermostats (${thermostats.map((t) => t.name).join(", ")}). Please say which one.`
);
}
const states = await readStates(
env,
target.structureId,
[target.id],
["Thermostat", "SimplifiedThermostat"]
);
const tr = states.states?.[0]?.traits || {};
const currentMode = tr.Thermostat?.state?.systemMode || tr.SimplifiedThermostat?.state?.systemMode || "SYSTEM_MODE_ENUM_AUTO";
const actions = [];
const notes = [];
if (wantMode) {
actions.push({
id: target.id,
type: target.type,
structureId: target.structureId,
command: "Thermostat.WriteAttributes",
parameters: { systemMode: modeEnum }
});
notes.push(`mode ${args.mode}`);
}
if (wantTemp) {
const askedF = Number(args.target_f);
const f = clampF(askedF);
if (f !== askedF) {
notes.push(`clamped ${askedF}F to ${f}F for safety (allowed ${MIN_F}-${MAX_F}F)`);
}
const celsius = Math.round(f2c(f) * 10) / 10;
const effectiveMode = modeEnum || currentMode;
const params = {};
if (effectiveMode === "SYSTEM_MODE_ENUM_COOL") {
params.occupiedCoolingSetpoint = celsius;
} else if (effectiveMode === "SYSTEM_MODE_ENUM_HEAT") {
params.occupiedHeatingSetpoint = celsius;
} else {
const dead = tr.Thermostat?.state?.minSetpointDeadBand ?? 1.7;
params.occupiedHeatingSetpoint = Math.round((celsius - dead / 2) * 10) / 10;
params.occupiedCoolingSetpoint = Math.round((celsius + dead / 2) * 10) / 10;
notes.push("mode is auto, so a heating/cooling range was set around the target");
}
actions.push({
id: target.id,
type: target.type,
structureId: target.structureId,
command: "Thermostat.WriteAttributes",
parameters: params
});
notes.unshift(`${f}F`);
}
await runActions(env, actions);
return text(`${target.name}: set ${notes.join("; ")}.`);
}
case "lock_door": {
const inv = await getInventory(env);
const locks = inv.devices.filter((d) => d.canLock);
if (!locks.length) return fail("No door lock found in this home.");
let target;
if (args?.name) {
const res = matchDevices(inv, args.name, {
home: args?.home,
filter: /* @__PURE__ */ __name((d) => d.canLock, "filter")
});
if (!res.devices.length) return describeMatch(res, args.name);
if (res.devices.length > 1) {
return fail(
`"${args.name}" matches ${res.devices.length} locks (${res.names.join(", ")}). Say which one. Locks are never operated as a group.`
);
}
target = res.devices[0];
} else if (locks.length === 1) {
target = locks[0];
} else {
return fail(
`There are ${locks.length} locks (${locks.map((l) => l.name).join(", ")}). Please say which one.`
);
}
await runActions(env, [{
id: target.id,
type: target.type,
structureId: target.structureId,
command: "DoorLock.LockDoor"
// takes no parameters; verified against the device
}]);
const after = await readStates(env, target.structureId, [target.id], ["DoorLock"]);
const st = after.states?.[0]?.traits?.DoorLock?.state?.lockState;
const pretty = {
DL_LOCK_STATE_LOCKED: "locked",
DL_LOCK_STATE_UNLOCKED: "STILL UNLOCKED",
DL_LOCK_STATE_NOT_FULLY_LOCKED: "NOT FULLY LOCKED - it may be jammed",
DL_LOCK_STATE_UNLATCHED: "UNLATCHED"
}[st] || (st ? `reported ${st}` : "state not reported");
const ok = st === "DL_LOCK_STATE_LOCKED";
const msg = `Sent lock to ${target.name}. It now reports: ${pretty}.`;
return ok ? text(msg) : fail(`${msg} Check it in the Google Home app.`);
}
case "get_status": {
const inv = await getInventory(env);
const res = matchDevices(inv, args?.name, { home: args?.home });
if (!res.devices.length) return describeMatch(res, args?.name);
const byStructure = /* @__PURE__ */ new Map();
for (const d of res.devices) {
if (!byStructure.has(d.structureId)) byStructure.set(d.structureId, []);
byStructure.get(d.structureId).push(d);
}
const lines = [];
for (const [structureId, devices] of byStructure) {
const states = await readStates(
env,
structureId,
devices.map((d) => d.id),
["OnOff", "Brightness", "Thermostat", "Connectivity", "DoorLock"]
);
for (const st of states.states || []) {
const dev = devices.find((d) => d.id === st.id);
const tr = st.traits || {};
const bits = [];
if (tr.OnOff?.state?.onOff !== void 0) {
bits.push(tr.OnOff.state.onOff ? "on" : "off");
}
if (tr.Brightness?.state?.currentBrightnessPercent !== void 0) {
bits.push(`${tr.Brightness.state.currentBrightnessPercent}% brightness`);
}
const th = tr.Thermostat?.state;
if (th) {
if (th.localTemperature !== void 0) {
bits.push(`currently ${Math.round(c2f(th.localTemperature))}F`);
}
if (th.systemMode) {
bits.push(`mode ${th.systemMode.replace("SYSTEM_MODE_ENUM_", "").toLowerCase()}`);
}
if (th.occupiedHeatingSetpoint !== void 0) {
bits.push(`heat to ${Math.round(c2f(th.occupiedHeatingSetpoint))}F`);
}
if (th.occupiedCoolingSetpoint !== void 0) {
bits.push(`cool to ${Math.round(c2f(th.occupiedCoolingSetpoint))}F`);
}
}
const lock = tr.DoorLock?.state;
if (lock?.lockState) {
bits.push({
DL_LOCK_STATE_LOCKED: "locked",
DL_LOCK_STATE_UNLOCKED: "unlocked",
DL_LOCK_STATE_NOT_FULLY_LOCKED: "not fully locked",
DL_LOCK_STATE_UNLATCHED: "unlatched"
}[lock.lockState] || lock.lockState);
}
if (st.resourceConnectivityState === "OFFLINE") bits.push("OFFLINE");
lines.push(`${dev?.name || st.displayName || st.id}: ${bits.join(", ") || "no state reported"}`);
}
}
const header = res.devices.length > 1 ? `${affected(res)}:
` : "";
return text(header + lines.join("\n"));
}
default:
return fail(`Unknown tool: ${name}`);
}
} catch (err) {
if (err instanceof NeedsGoogleAuth) {
return fail(
`${err.message} Open /google/start on this server and sign in again to restore access.`
);
}
return fail(`Error: ${err.message}`);
}
}
__name(callTool, "callTool");
// src/index.js
var BRIDGE_VERSION = "1.1.0";
var PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
var MIN_PASSPHRASE = 16;
function passphrase(env) {
const p = secret(env.PROXY_PASSPHRASE);
return p && p.length >= MIN_PASSPHRASE ? p : null;
}
__name(passphrase, "passphrase");
async function passphraseFingerprint(env) {
const p = passphrase(env);
return p ? (await sha256b64url("fp:" + p)).slice(0, 22) : null;
}
__name(passphraseFingerprint, "passphraseFingerprint");
var ABUSE_LIMIT_PER_HOUR = 12;
var abuseKey = /* @__PURE__ */ __name(() => `abuse:${(/* @__PURE__ */ new Date()).toISOString().slice(0, 13)}`, "abuseKey");
async function overBudget(env) {
return parseInt(await env.OAUTH_KV.get(abuseKey()) || "0", 10) >= ABUSE_LIMIT_PER_HOUR;
}
__name(overBudget, "overBudget");
async function spendBudget(env) {
const k = abuseKey();
const n = parseInt(await env.OAUTH_KV.get(k) || "0", 10);
await env.OAUTH_KV.put(k, String(n + 1), { expirationTtl: 7200 });
}
__name(spendBudget, "spendBudget");
var pause = /* @__PURE__ */ __name((ms) => new Promise((r) => setTimeout(r, ms)), "pause");
var keyFor = /* @__PURE__ */ __name(async (kind, token) => `${kind}:${await sha256b64url(token)}`, "keyFor");
var TOKEN_TTL = 3600;
var REFRESH_TTL = 60 * 60 * 24 * 90;
var CODE_TTL = 600;
var CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, MCP-Protocol-Version"
};
function json(body, status = 200, extra = {}) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...CORS, ...extra }
});
}
__name(json, "json");
function b64url(bytes) {
let s = "";
for (const b of new Uint8Array(bytes)) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
__name(b64url, "b64url");
function randomToken(len = 32) {
return b64url(crypto.getRandomValues(new Uint8Array(len)));
}
__name(randomToken, "randomToken");
async function sha256b64url(str) {
return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str)));
}
__name(sha256b64url, "sha256b64url");
function safeEqual(a, b) {
const ab = new TextEncoder().encode(a);
const bb = new TextEncoder().encode(b);
let diff = ab.length ^ bb.length;
const n = Math.max(ab.length, bb.length);
for (let i = 0; i < n; i++) diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
return diff === 0;
}
__name(safeEqual, "safeEqual");
var ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
var escapeHtml = /* @__PURE__ */ __name((s) => String(s).replace(/[&<>"']/g, (c) => ESCAPES[c]), "escapeHtml");
function authServerMetadata(origin) {
return {
issuer: origin,
authorization_endpoint: `${origin}/authorize`,
token_endpoint: `${origin}/token`,
registration_endpoint: `${origin}/register`,
scopes_supported: ["home"],
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
code_challenge_methods_supported: ["S256"]
};
}
__name(authServerMetadata, "authServerMetadata");
function resourceMetadata(origin) {
return {
resource: `${origin}/mcp`,
authorization_servers: [origin],
bearer_methods_supported: ["header"],
scopes_supported: ["home"]
};
}
__name(resourceMetadata, "resourceMetadata");
var DEFAULT_ALLOWED_REDIRECT_HOSTS = [
"grok.com",
"x.ai",
"x.com",
// Grok: app, web, and in-car
"claude.ai",
"anthropic.com",
"localhost",
"127.0.0.1"
// local testing
];
function allowedRedirectHosts(env) {
const configured = secret(env.ALLOWED_REDIRECT_HOSTS);
if (!configured) return DEFAULT_ALLOWED_REDIRECT_HOSTS;
return configured.split(",").map((h) => h.trim().toLowerCase()).filter(Boolean);
}
__name(allowedRedirectHosts, "allowedRedirectHosts");
function isAllowedRedirect(uri, env) {
let parsed;
try {
parsed = new URL(uri);
} catch {
return false;
}
const host = parsed.hostname.toLowerCase();
return allowedRedirectHosts(env).some((a) => host === a || host.endsWith(`.${a}`));
}
__name(isAllowedRedirect, "isAllowedRedirect");
async function handleRegister(request, env) {
if (await overBudget(env)) {
return json({
error: "temporarily_unavailable",
error_description: "Too many recent attempts. Try again within the hour."
}, 429);
}
let body;
try {
body = await request.json();
} catch {
return json({ error: "invalid_client_metadata", error_description: "body must be JSON" }, 400);
}
const redirectUris = body.redirect_uris;
if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
return json({ error: "invalid_redirect_uri", error_description: "redirect_uris required" }, 400);
}
for (const uri of redirectUris) {
let parsed;
try {
parsed = new URL(uri);
} catch {
return json({ error: "invalid_redirect_uri", error_description: `not a URL: ${uri}` }, 400);
}
const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
if (parsed.protocol !== "https:" && !loopback) {
return json({ error: "invalid_redirect_uri", error_description: `must be https: ${uri}` }, 400);
}
if (!isAllowedRedirect(uri, env)) {
return json({
error: "invalid_redirect_uri",
error_description: `redirect host not allowed: ${parsed.hostname}. Allowed: ${allowedRedirectHosts(env).join(", ")}. Set ALLOWED_REDIRECT_HOSTS to add one.`
}, 400);
}
}
const clientId = randomToken(16);
const record = {
client_id: clientId,
client_name: typeof body.client_name === "string" ? body.client_name.slice(0, 120) : "unnamed",
redirect_uris: redirectUris,
created: Date.now(),
approved: false
};
await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(record), { expirationTtl: 3600 });
await spendBudget(env);
return json(
{
client_id: clientId,
client_name: record.client_name,
redirect_uris: redirectUris,
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none"
},
201
);
}
__name(handleRegister, "handleRegister");
var HIDDEN_FIELDS = [
"client_id",
"redirect_uri",
"state",
"code_challenge",
"code_challenge_method",
"scope",
"resource"
];
function loginPage(params, error) {
const hidden = HIDDEN_FIELDS.filter((k) => params.get(k)).map((k) => `<input type="hidden" name="${k}" value="${escapeHtml(params.get(k))}">`).join("\n ");
const who = params.get("_client_name") || params.get("client_id") || "unknown";
const dest = params.get("redirect_uri") || "";
let destHost = "unknown";
try {
destHost = new URL(dest).host;
} catch {
}
return new Response(
`<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Authorize</title>
<style>
:root { color-scheme: light dark;
--bg:#fff; --fg:#111; --mut:#666; --bd:#d4d4d8; --acc:#2563eb; --err:#b91c1c; }
@media (prefers-color-scheme: dark) {
:root { --bg:#111214; --fg:#f4f4f5; --mut:#a1a1aa; --bd:#3f3f46; --acc:#60a5fa; --err:#f87171; }
}
* { box-sizing:border-box }
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:16px;
background:var(--bg); color:var(--fg);
font:15px/1.5 system-ui,-apple-system,Segoe UI,sans-serif }
.card { width:100%; max-width:380px; border:1px solid var(--bd); border-radius:12px; padding:24px }
h1 { margin:0 0 4px; font-size:17px }
p.sub { margin:0 0 20px; color:var(--mut); font-size:13px }
label { display:block; font-size:13px; font-weight:600; margin-bottom:6px }
input[type=password] { width:100%; padding:10px 12px; font-size:15px; border-radius:8px;
border:1px solid var(--bd); background:transparent; color:var(--fg) }
button { width:100%; margin-top:16px; padding:11px; font-size:15px; font-weight:600;
border:0; border-radius:8px; background:var(--acc); color:#fff; cursor:pointer }
.err { color:var(--err); font-size:13px; margin:0 0 14px }
.who { font-size:12px; color:var(--mut); margin-top:16px; word-break:break-all }
.dest { border:1px solid var(--bd); border-radius:8px; padding:12px 14px; margin:0 0 18px;
font-size:13px; background:rgba(127,127,127,.06) }
.dest .host { font-weight:700; font-size:15px; word-break:break-all }
.dest .lbl { color:var(--mut); display:block; margin-bottom:2px }
</style></head>
<body><div class="card">
<h1>Authorize MCP access</h1>
<p class="sub">A client is asking for control of your smart home devices.</p>
${error ? `<p class="err">${escapeHtml(error)}</p>` : ""}
<div class="dest">
<span class="lbl">Access will be sent to</span>
<span class="host">${escapeHtml(destHost)}</span>
</div>
<p class="sub">If you did not just add a connector on that site, close this page.</p>
<form method="POST" action="/authorize">
${hidden}
<label for="pp">Passphrase</label>
<input id="pp" name="passphrase" type="password" autocomplete="current-password" autofocus required>
<button type="submit">Authorize ${escapeHtml(destHost)}</button>
</form>
<p class="who">Client name (self-reported): ${escapeHtml(who)}</p>
</div></body></html>`,
{
status: error ? 401 : 200,
headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }
}
);
}
__name(loginPage, "loginPage");
async function handleAuthorizeGet(url, env) {
const p = url.searchParams;
const clientId = p.get("client_id");
const redirectUri = p.get("redirect_uri");
if (!clientId || !redirectUri) {
return json(
{ error: "invalid_request", error_description: "client_id and redirect_uri required" },
400
);
}
const raw = await env.OAUTH_KV.get(`client:${clientId}`);
if (!raw) return json({ error: "invalid_client" }, 400);
const client = JSON.parse(raw);
if (!client.redirect_uris.includes(redirectUri)) {
return json({ error: "invalid_request", error_description: "redirect_uri mismatch" }, 400);
}
if (p.get("code_challenge_method") !== "S256" || !p.get("code_challenge")) {
return json({ error: "invalid_request", error_description: "PKCE S256 required" }, 400);
}
if (!isAllowedRedirect(redirectUri, env)) {
return json({
error: "invalid_request",
error_description: "redirect host is not allowed"
}, 400);
}
p.set("_client_name", client.client_name);
return loginPage(p);
}
__name(handleAuthorizeGet, "handleAuthorizeGet");
async function handleAuthorizePost(request, env) {
const form = await request.formData();
const p = new URLSearchParams();
for (const [k, v] of form.entries()) if (k !== "passphrase") p.set(k, v);
const clientId = p.get("client_id");
const redirectUri = p.get("redirect_uri");
const raw = clientId ? await env.OAUTH_KV.get(`client:${clientId}`) : null;
if (!raw) return json({ error: "invalid_client" }, 400);
const client = JSON.parse(raw);
if (!client.redirect_uris.includes(redirectUri)) {
return json({ error: "invalid_request", error_description: "redirect_uri mismatch" }, 400);
}
if (!isAllowedRedirect(redirectUri, env)) {
return json({ error: "invalid_request", error_description: "redirect host is not allowed" }, 400);
}
const pp = passphrase(env);
if (!pp) {
return json({ error: "server_error", error_description: `PROXY_PASSPHRASE is missing or shorter than ${MIN_PASSPHRASE} characters.` }, 500);
}
if (await overBudget(env)) {
p.set("_client_name", client.client_name);
return loginPage(p, "Too many attempts. Try again within the hour.");
}
if (!safeEqual(form.get("passphrase") || "", pp)) {
await spendBudget(env);
await pause(1e3);
p.set("_client_name", client.client_name);
return loginPage(p, "Incorrect passphrase.");
}
if (!client.approved) {
await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify({
...client,
approved: true,
approvedAt: (/* @__PURE__ */ new Date()).toISOString()
}));
}
const code = randomToken(32);
await env.OAUTH_KV.put(
await keyFor("code", code),
JSON.stringify({
client_id: clientId,
redirect_uri: redirectUri,
code_challenge: p.get("code_challenge"),
scope: p.get("scope") || "home"
}),
{ expirationTtl: CODE_TTL }
);
const dest = new URL(redirectUri);
dest.searchParams.set("code", code);
if (p.get("state")) dest.searchParams.set("state", p.get("state"));
return new Response(null, { status: 302, headers: { Location: dest.toString() } });
}
__name(handleAuthorizePost, "handleAuthorizePost");
async function issueTokens(env, clientId, scope) {
const access = randomToken(32);
const refresh = randomToken(32);
const payload = JSON.stringify({ client_id: clientId, scope, fp: await passphraseFingerprint(env) });
await env.OAUTH_KV.put(await keyFor("tok", access), payload, { expirationTtl: TOKEN_TTL });
await env.OAUTH_KV.put(await keyFor("refresh", refresh), payload, { expirationTtl: REFRESH_TTL });
return json({
access_token: access,
token_type: "Bearer",
expires_in: TOKEN_TTL,
refresh_token: refresh,
scope
});
}
__name(issueTokens, "issueTokens");
async function handleToken(request, env) {
const form = await request.formData();
const grant = form.get("grant_type");
if (grant === "authorization_code") {
const code = form.get("code");
const verifier = form.get("code_verifier");
if (!code || !verifier) {
return json(
{ error: "invalid_request", error_description: "code and code_verifier required" },
400
);
}
const codeKey = await keyFor("code", code);
const raw = await env.OAUTH_KV.get(codeKey);
if (!raw) return json({ error: "invalid_grant", error_description: "code invalid or expired" }, 400);
await env.OAUTH_KV.delete(codeKey);
const rec = JSON.parse(raw);
if (form.get("client_id") && form.get("client_id") !== rec.client_id) {
return json({ error: "invalid_grant", error_description: "client mismatch" }, 400);
}
if (rec.redirect_uri !== form.get("redirect_uri")) {
return json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }, 400);
}
if (await sha256b64url(verifier) !== rec.code_challenge) {
return json({ error: "invalid_grant", error_description: "PKCE verification failed" }, 400);
}
return issueTokens(env, rec.client_id, rec.scope);
}
if (grant === "refresh_token") {
const rt = form.get("refresh_token");
const rtKey = rt ? await keyFor("refresh", rt) : null;
const raw = rtKey ? await env.OAUTH_KV.get(rtKey) : null;
if (!raw) return json({ error: "invalid_grant", error_description: "refresh_token invalid" }, 400);
const rec = JSON.parse(raw);
await env.OAUTH_KV.delete(rtKey);
if (!rec.fp || rec.fp !== await passphraseFingerprint(env)) {
return json({ error: "invalid_grant", error_description: "revoked: the passphrase has changed" }, 400);
}
return issueTokens(env, rec.client_id, rec.scope);
}
return json({ error: "unsupported_grant_type" }, 400);
}
__name(handleToken, "handleToken");
async function handleRpc(msg, env) {
const { id, method, params } = msg;
const reply = /* @__PURE__ */ __name((result) => ({ jsonrpc: "2.0", id, result }), "reply");
const fail2 = /* @__PURE__ */ __name((code, message) => ({ jsonrpc: "2.0", id, error: { code, message } }), "fail");
switch (method) {
case "initialize": {
const asked = params?.protocolVersion;
return reply({
protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
capabilities: { tools: {} },
serverInfo: { name: "google-home-mcp-bridge", version: BRIDGE_VERSION }
});
}
case "ping":
return reply({});
case "tools/list":
return reply({ tools: TOOLS });
case "tools/call": {
if (!params?.name) return fail2(-32602, "params.name required");
try {
return reply(await callTool(params.name, params.arguments || {}, env));
} catch (err) {
return reply({ content: [{ type: "text", text: `Error: ${err.message}` }], isError: true });
}
}
default:
return fail2(-32601, `Method not found: ${method}`);
}
}
__name(handleRpc, "handleRpc");
async function handleMcp(request, env, origin) {
const unauthorized = /* @__PURE__ */ __name((desc) => json({ error: "invalid_token", error_description: desc }, 401, {
"WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`
}), "unauthorized");
const auth = request.headers.get("Authorization") || "";
if (!auth.startsWith("Bearer ")) return unauthorized("Bearer token required");
const tok = await env.OAUTH_KV.get(await keyFor("tok", auth.slice(7).trim()));
if (!tok) return unauthorized("token invalid or expired");
const fp = await passphraseFingerprint(env);
if (!fp || JSON.parse(tok).fp !== fp) return unauthorized("revoked: the passphrase has changed");
let body;
try {
body = await request.json();
} catch {
return json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }, 400);
}
const batch = Array.isArray(body) ? body : [body];
const out = [];
for (const msg of batch) {
if (msg?.id === void 0 || msg?.id === null) continue;
out.push(await handleRpc(msg, env));
}
if (out.length === 0) return new Response(null, { status: 202, headers: CORS });
return json(Array.isArray(body) ? out : out[0]);
}
__name(handleMcp, "handleMcp");
function page(title, bodyHtml, status = 200) {
return new Response(
`<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title}</title>
<style>
:root { color-scheme: light dark; --bg:#fff; --fg:#111; --mut:#666; --bd:#d4d4d8; --acc:#2563eb; --err:#b91c1c; --ok:#15803d; }
@media (prefers-color-scheme: dark) {
:root { --bg:#111214; --fg:#f4f4f5; --mut:#a1a1aa; --bd:#3f3f46; --acc:#60a5fa; --err:#f87171; --ok:#4ade80; }
}
* { box-sizing:border-box }
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:16px;
background:var(--bg); color:var(--fg);
font:16px/1.6 system-ui,-apple-system,Segoe UI,sans-serif }
.card { width:100%; max-width:420px; border:1px solid var(--bd); border-radius:12px; padding:24px }
h1 { margin:0 0 8px; font-size:18px }
p { margin:0 0 16px; color:var(--mut); font-size:14px }
label { display:block; font-size:14px; font-weight:600; margin-bottom:6px }
input[type=password] { width:100%; padding:12px; font-size:16px; border-radius:8px;
border:1px solid var(--bd); background:transparent; color:var(--fg) }
button { width:100%; margin-top:16px; padding:13px; font-size:16px; font-weight:600;
border:0; border-radius:8px; background:var(--acc); color:#fff; cursor:pointer }
.err { color:var(--err); font-size:14px }
.ok { color:var(--ok); font-size:14px }
ul.checklist { list-style:none; padding:0; margin:0 0 20px }
ul.checklist li { display:flex; gap:10px; padding:8px 0; border-bottom:1px solid var(--bd); font-size:14px }
ul.checklist li span { width:18px; text-align:center; font-weight:700 }
ul.checklist li.ok span { color:var(--ok) }
ul.checklist li.todo { color:var(--mut) }
p.copy code { display:block; padding:10px 12px; border:1px solid var(--bd); border-radius:8px;
word-break:break-all; font-size:13px; color:var(--fg) }
a { color:var(--acc) }
</style></head>
<body><div class="card">${bodyHtml}</div></body></html>`,
{ status, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } }
);
}
__name(page, "page");
function googleStartForm(error, clientId, origin = "") {
const idNote = clientId ? `<p style="font-size:12px;word-break:break-all">Client: <code>${escapeHtml(clientId)}</code><br>(${clientId.length} characters)</p>` : '<p class="err">GOOGLE_CLIENT_ID is not set.</p>';
const redirectNote = origin ? `<p style="font-size:12px;word-break:break-all">Redirect URI to register in Google Cloud:<br><code>${escapeHtml(origin)}/google/callback</code></p>` : "";
return page(
"Reconnect Google Home",
`<h1>Reconnect Google Home</h1>
<p>Re-authorize this bridge to reach your devices.</p>
${error ? `<p class="err">${escapeHtml(error)}</p>` : ""}
${idNote}
${redirectNote}
<form method="POST" action="/google/start">
<label for="pp">Passphrase</label>
<input id="pp" name="passphrase" type="password" autocomplete="current-password" autofocus required>
<button type="submit">Continue to Google</button>
</form>`,
error ? 401 : 200
);
}
__name(googleStartForm, "googleStartForm");
async function handleGoogleStart(request, env, origin) {
const clientId = secret(env.GOOGLE_CLIENT_ID);
if (request.method === "GET") return googleStartForm(null, clientId, origin);
const form = await request.formData();
const pp = passphrase(env);
if (!pp) {
return page("Error", `<h1>Not configured</h1><p>PROXY_PASSPHRASE is missing or shorter than ${MIN_PASSPHRASE} characters.</p>`, 500);
}
if (await overBudget(env)) {
return googleStartForm("Too many attempts. Try again within the hour.", clientId, origin);
}
if (!safeEqual(form.get("passphrase") || "", pp)) {
await spendBudget(env);
await pause(1e3);
return googleStartForm("Incorrect passphrase.", clientId, origin);
}
if (!clientId) {
return page("Error", "<h1>Not configured</h1><p>GOOGLE_CLIENT_ID is unset.</p>", 500);
}
const state = randomToken(24);
await env.OAUTH_KV.put(`gstate:${state}`, "1", { expirationTtl: 600 });
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
url.searchParams.set("client_id", clientId);
url.searchParams.set("redirect_uri", `${origin}/google/callback`);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", HOME_SCOPE);
url.searchParams.set("access_type", "offline");
url.searchParams.set("prompt", "consent");
url.searchParams.set("state", state);
return new Response(null, { status: 302, headers: { Location: url.toString() } });
}
__name(handleGoogleStart, "handleGoogleStart");
async function handleGoogleCallback(url, env, origin) {
const state = url.searchParams.get("state");
const err = url.searchParams.get("error");
const code = url.searchParams.get("code");
const known = state ? await env.OAUTH_KV.get(`gstate:${state}`) : null;
if (!known) {
return page("Failed", "<h1>Expired or invalid</h1><p>Start again at /google/start.</p>", 400);
}
await env.OAUTH_KV.delete(`gstate:${state}`);
if (err || !code) {
return page("Failed", `<h1>Authorization failed</h1><p class="err">${escapeHtml(err || "no code returned")}</p>`, 400);
}
try {
const refresh = await exchangeCodeForRefreshToken(env, code, `${origin}/google/callback`);
await storeRefreshToken(env, refresh);
return page(
"Connected",
'<h1 class="ok">Connected</h1><p>Google Home access has been restored. You can close this page and use the assistant again.</p>'
);
} catch (e) {
return page("Failed", `<h1>Could not complete</h1><p class="err">${escapeHtml(e.message)}</p>`, 400);
}
}
__name(handleGoogleCallback, "handleGoogleCallback");
async function setupPage(env, origin) {
const hasStorage = !!env.OAUTH_KV;
const s = hasStorage ? await authStatus(env) : { hasRefreshToken: false };
const steps = [
["Storage connected (KV binding named OAUTH_KV)", hasStorage],
[`Passphrase set (PROXY_PASSPHRASE, at least ${MIN_PASSPHRASE} characters)`, !!passphrase(env)],
["Google client ID set (GOOGLE_CLIENT_ID)", !!secret(env.GOOGLE_CLIENT_ID)],
["Google client secret set (GOOGLE_CLIENT_SECRET)", !!secret(env.GOOGLE_CLIENT_SECRET)],
["Google account connected", s.hasRefreshToken]
];
if (steps.every(([, ok]) => ok)) {
return page(
"Setup complete",
`<h1>Setup complete</h1>
<p>This bridge is configured.</p>
<label>Assistant connector URL</label>
<p class="copy"><code>${escapeHtml(origin)}/mcp</code></p>
<p><a href="/google/start">Reconnect Google →</a></p>
<p style="font-size:12px">Version ${BRIDGE_VERSION}</p>`
);
}
const rows = steps.map(([label, ok]) => `<li class="${ok ? "ok" : "todo"}"><span>${ok ? "✓" : "○"}</span>${escapeHtml(label)}</li>`).join("");
return page(
"Bridge setup",
`<h1>Bridge setup</h1>
<p>Setup checklist. Each item turns green when it is done.</p>
<ul class="checklist">${rows}</ul>
<label>Google OAuth redirect URI</label>
<p class="copy"><code>${escapeHtml(origin)}/google/callback</code></p>
<label>Grok custom connector URL</label>
<p class="copy"><code>${escapeHtml(origin)}/mcp</code></p>
<p><a href="/google/start">Connect or reconnect Google →</a></p>
<p style="font-size:12px">Version ${BRIDGE_VERSION}</p>`
);
}
__name(setupPage, "setupPage");
var index_default = {
async fetch(request, env) {
const url = new URL(request.url);
const origin = url.origin;
const path = url.pathname.replace(/\/+$/, "") || "/";
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS });
}
if (path === "/") return setupPage(env, origin);
if (!env.OAUTH_KV) {
return json({
error: "server_error",
error_description: "Storage is not connected. Bind a KV namespace with the variable name OAUTH_KV, then reload. See the setup guide."
}, 500);
}
if (path === "/.well-known/oauth-authorization-server") {
return json(authServerMetadata(origin));
}
if (path === "/.well-known/oauth-protected-resource" || path === "/.well-known/oauth-protected-resource/mcp") {
return json(resourceMetadata(origin));
}
if (path === "/register" && request.method === "POST") return handleRegister(request, env);
if (path === "/authorize" && request.method === "GET") return handleAuthorizeGet(url, env);
if (path === "/authorize" && request.method === "POST") return handleAuthorizePost(request, env);
if (path === "/token" && request.method === "POST") return handleToken(request, env);
if (path === "/google/start" && (request.method === "GET" || request.method === "POST")) {
return handleGoogleStart(request, env, origin);
}
if (path === "/google/callback" && request.method === "GET") {
return handleGoogleCallback(url, env, origin);
}
if (path === "/mcp") {
if (request.method === "POST") return handleMcp(request, env, origin);
return json(
{ error: "method_not_allowed", error_description: "POST JSON-RPC to /mcp" },
405,
{ Allow: "POST, OPTIONS" }
);
}
return json({ error: "not_found" }, 404);
}
};
export {
index_default as default
};
Version 1.1.0. Scroll the box to read it first; you don't need to change anything in it.
- Sign in to dash.cloudflare.com, or create a free account.
- Open Workers & Pages, choose Create application, and start from the Hello World template. Name it something like
google-home-bridge, then deploy it. - Choose Edit code. Select everything in the editor, delete it, paste in the bridge code, and deploy again.
- Open your Worker's address, shown on its overview page. It looks like
https://google-home-bridge.your-name.workers.dev.
The bridge's home page is a setup checklist. It shows what's done, what's left, and the two addresses you'll copy in later steps. Keep that tab open; each item turns green as you finish it.
New Cloudflare account? Cloudflare may ask you to choose a workers.dev subdomain first. It becomes part of your address, so pick something that doesn't identify you. A brand-new address can also show a security error for about a minute while its certificate is issued. Wait, then reload.
2Add storage and your passphrase
- Open Workers KV (under Storage & databases), choose Create instance, give it any name, and create it.
- Back on your Worker, open the Bindings tab and choose Add binding, then KV namespace. For Variable name, enter exactly
OAUTH_KV, select the storage you just created, and choose Add binding. - Open Settings → Variables and Secrets and choose Add. Set the type to Secret, name it
PROXY_PASSPHRASE, and paste a long passphrase, such as five or six random words. It must be at least 16 characters; the bridge refuses to work with anything shorter. Save it in your password manager. It's what stops anyone else from connecting to your bridge.
Reload the setup page. Storage connected and Passphrase set should now be green.
3Create a Google Cloud project and enable the Home API
- Go to console.cloud.google.com, open the project picker at the top, and choose New Project. Any name works.
- With that project selected, open the Home API page and click Enable.
Use that direct link rather than searching. Search also turns up the older Smart Device Management API, which is a different product and won't work here.
4Set up the Google sign-in screen
- Open Google Auth Platform and click Get started.
- Enter an app name and your email, and choose External as the audience.
- Under Audience, add the Google account your Google Home lives on as a test user.
- Under Data access, choose Add or remove scopes. The Home scope usually isn't in the list, so paste it into Manually add scopes, add it, and save:
https://www.googleapis.com/auth/home.platform.v2
5Create the OAuth client (your app's sign-in credentials)
- Open Clients and choose Create client.
- Set the application type to Web application. Desktop won't work.
- Under Authorized redirect URIs, add the Google OAuth redirect URI shown on your bridge's setup page. It ends in
/google/callback. - Create the client. You need the Client ID and Client secret next. Choose Download JSON too and keep the file somewhere private: Google may not show the secret again later.
6Give the bridge your Google credentials
- In the Cloudflare dashboard, open Workers & Pages, then your bridge, then Settings → Variables and Secrets.
- Add a variable named
GOOGLE_CLIENT_ID, set its type to Secret, and paste the client ID. - Add
GOOGLE_CLIENT_SECRETthe same way with the client secret.
Reload your bridge's setup page. Everything except Google account connected should now be green.
7Connect your Google account
- On the setup page, choose Connect or reconnect Google. This opens
/google/start. - Enter your passphrase and continue to Google.
- Sign in with the account your Google Home lives on.
- You'll see "Google hasn't verified this app." That's expected, because the app is yours and Google hasn't reviewed it. Choose Advanced, then Go to your app name (unsafe).
- Choose which home to share and allow access. You should land on a page that says Connected.
Your bridge's home page now just says Setup complete. It hides the checklist once you're done, because anyone can open that page.
8Add a Grok custom connector
- In the Grok app or at grok.com, open Connectors, choose New Connector, then Custom.
- Give it a name, such as Google Home, and paste the Grok custom connector URL from your setup page. It ends in
/mcp. - Grok opens your bridge's approval page. Check that it says access will be sent to grok.com, then enter your passphrase.
9Try it, then try it in the car
In the Grok app, ask "what devices are in my kitchen?" If Grok lists them, the connection works end to end. Once you're signed in to Grok in your Tesla, the connector is already there. Ask the same thing from the car.
What you can say
| Try saying | What happens |
|---|---|
| "Turn off the kitchen light" | Switches every bulb named Kitchen Light together |
| "Turn on the backyard lights" | Switches every light in the Backyard room, including smart plugs with "light" in the name |
| "Dim the living room light to 30 percent" | Sets brightness |
| "Make the bedroom light blue" | Sets color on color bulbs |
| "Set the house to 70" | Sets the thermostat in Fahrenheit, limited to 50 to 90 degrees |
| "Lock the front door" | Locks, then reports whether it really locked |
| "Is the porch light on?" | Reads the current state |
Devices that share a name are treated as one group, so you don't have to name individual bulbs. If you have more than one home, the bridge asks which one rather than guessing.
Locks only lock. The bridge can lock a door but has no way to unlock one. Unlock from the Google Home app, the keypad, or a key.
Keeping it connected
Google expires the sign-in every 7 days while your Google app is in "Testing", which is where a personal app normally stays. When Grok starts saying it can't reach your devices, reconnect:
- Bookmark
https://google-home-bridge.your-name.workers.dev/google/starton your phone, using your own bridge's address. - Open it, enter your passphrase, and allow access again. It takes about ten seconds.
You can also ask Grok to "check the Google Home connection status". It reports how many days ago you last connected.
Troubleshooting: Grok connectors not working
Grok only shows one tool, or says it has no smart home tools
Grok remembers a connector's tools from when you added it. Delete the connector in Grok and add it again with the same URL. You only re-enter your passphrase; your Google connection stays.
"Too many attempts. Try again within the hour."
After several wrong passphrases or new connection attempts, the bridge pauses sign-ins for the rest of the hour. This is a safety limit that stops anyone from guessing your passphrase. Wait, then try again with the passphrase from your password manager.
"PROXY_PASSPHRASE is missing or shorter than 16 characters"
Set a longer passphrase in Cloudflare (Step 2). Five or six random words is plenty.
"Access blocked: Authorization Error" or "Error 401: invalid_client"
Google doesn't recognize the client ID. It was probably mistyped: client IDs are long, and a l read as a 1 is enough. The /google/start page shows the ID your bridge is sending. Compare it with Google Cloud and re-paste it in Cloudflare.
"invalid_client: The provided client secret is invalid"
Same cause, other field. Copy the secret from Google Cloud and paste it into GOOGLE_CLIENT_SECRET again.
"Error 400: redirect_uri_mismatch"
The redirect URI in Google Cloud has to match your bridge's exactly, including the /google/callback ending. Copy it from the setup page. Google can take a few minutes to apply changes, so wait and retry before editing again.
"Access blocked: this app has not completed the Google verification process"
The account you signed in with isn't a test user. Add it under Audience in Google Auth Platform (Step 4).
Connected, but Grok finds no devices
Make sure you signed in with the same Google account your home lives on, and that it has Google Home Premium Advanced. The Home MCP server doesn't return devices without it.
Adding the connector fails, or mentions "redirect host not allowed"
The bridge only lets known assistants connect. Grok and Claude are allowed by default. To add another, create a secret named ALLOWED_REDIRECT_HOSTS listing every assistant domain you use, separated by commas, for example grok.com,x.ai,x.com,claude.ai,example-assistant.com. Setting it replaces the default list, so keep the ones you still use.
Security and privacy
- It runs in your account. Your Google credentials are stored as encrypted secrets in your own Cloudflare account, and connection tokens are stored only in scrambled (hashed) form.
- Your passphrase gates every connection. Knowing your bridge's address isn't enough to use it, and the bridge refuses passphrases shorter than 16 characters.
- Guessing is blocked. After a handful of wrong passphrases, sign-ins pause for the rest of the hour.
- Only known assistants can connect. The bridge refuses connection requests from any site other than Grok or Claude, unless you add one yourself.
- Cameras, doorbells, and history are never exposed, and locks can only be locked, never unlocked.
- Anyone who can use your Grok can use these commands. That includes passengers using the car's voice assistant and anyone with access to your Grok account or phone.
- Grok acts on what it reads. If you also connect Grok to email, files, or the web, content there could influence what it does. Keep device control to commands you give yourself. If Grok ever does something you didn't ask for, turn the connector off.
- Grok sees your home's details. Requests go from Grok, to your bridge, to Google. Your home, room, and device names and their current states, including temperatures and lock status, pass through Grok, so xAI's privacy terms apply to that part.
If you think someone else has access
- Change your passphrase. In Cloudflare, update
PROXY_PASSPHRASEto a new one. Every connected assistant is disconnected within about a minute. - Revoke Google access. Visit myaccount.google.com/permissions and remove your app.
- Reconnect what you trust. Reconnect Google at
/google/start, then delete and re-add the connector in Grok using the new passphrase. - Check your home. In the Google Home app, confirm your devices, especially locks and the thermostat, are in the state you expect.
Disclaimer
Use at your own risk. The bridge is provided as-is, without warranty of any kind, under the MIT license included in the code.
It is not a security or safety system. Don't rely on it to lock your home, heat or cool it, or protect people, pets, or property. Commands can fail, arrive late, or affect a different device than you meant. Check important changes, like a lock or the thermostat, in the Google Home app.
Things outside my control can change. Google, Cloudflare, xAI, and Tesla can change or stop the services this depends on at any time.
You're responsible for your setup: your accounts, your passphrase, your Google credentials, and your devices.
FAQ
Do I need to know how to code?
No. You copy the code from this page and paste it into Cloudflare's editor, and every other step happens in a web page: Cloudflare's dashboard, Google Cloud, and the Grok app.
What does it cost?
The bridge runs on Cloudflare's free plan, and the Google Cloud project is free. Google requires Google Home Premium Advanced for its Home MCP server, which is a paid Google subscription.
Can I connect Claude instead of Grok?
Claude can connect to Google's Home MCP server directly, without this bridge, because its custom connectors accept your own Google credentials. The bridge also allows Claude, so either route works.
Will this unlock my front door?
No. The bridge can lock a door lock but refuses every other lock command, including unlock.
Does this control my Tesla from Google Home?
No, this goes the other way: Grok in your Tesla controls your Google Home devices. Controlling the car itself is a separate integration.
Why do I have to reconnect every week?
Google limits sign-ins for apps in "Testing" to 7 days. Moving an app out of Testing needs a public homepage and privacy policy, and for the Home permission, Google's verification review. That process is built for companies rather than single households.
Version history
If a security fix ships, it's listed here. To update, copy the code again and paste it over the old code in Cloudflare. Your settings and connection stay.
| Version | Changes |
|---|---|
| 1.1.0 | Security hardening: limits on wrong passphrases and connection attempts, changing the passphrase disconnects every assistant, connection tokens stored only in hashed form, minimum 16-character passphrase, and the public setup page hides details once setup is complete. |
| 1.0.1 | First public release. |
The bridge is open source under the MIT license. You can read the full code in the box in Step 1, and the license and warranty disclaimer are at the top.