diff --git a/app/src/server/gcal.ts b/app/src/server/gcal.ts index 5d7e641..2f7081c 100644 --- a/app/src/server/gcal.ts +++ b/app/src/server/gcal.ts @@ -37,6 +37,10 @@ export class GoogleOAuthProvider extends DurableObjectOAuthClientProvider { private readonly tokenStore: GcalTokenStore ) { super(storage, clientName, fixedRedirectUrl); + // The stock provider only learns its clientId from dynamic registration, + // which Google doesn't do — and its clientId getter THROWS when unset, + // killing the auth leg inside saveCodeVerifier. Pin it up front. + this.clientId = client.client_id; } override get redirectUrl(): string { @@ -72,6 +76,9 @@ export class GoogleOAuthProvider extends DurableObjectOAuthClientProvider { } override async redirectToAuthorization(authUrl: URL): Promise { + // The SDK derives scopes from the server's resource metadata, which + // advertises WRITE scopes too — pin the request to our read-only trio. + authUrl.searchParams.set("scope", GCAL_SCOPES.join(" ")); // Without offline access Google issues no refresh token and the // connection would die within the hour; prompt=consent guarantees a // refresh token on re-grants too. diff --git a/app/src/server/pi.ts b/app/src/server/pi.ts index 815c900..6db6b81 100644 --- a/app/src/server/pi.ts +++ b/app/src/server/pi.ts @@ -167,15 +167,6 @@ export class Pi extends Think { }); if (result.state === "authenticating") { authUrls.gcal = result.authUrl; - } else if (this.isDesk() && !(await this.gcalTokensHas())) { - // Google's MCP server answers initialize and tools/list - // anonymously, so the connection lands "ready" without ever - // triggering OAuth — the 401 only appears on a real tool call. - // Force one so the SDK starts the authorization leg, then - // surface the consent URL it produced. - const url = await this.forceGcalConsent(); - if (url) authUrls.gcal = url; - else appErrors.gcal = "couldn't start Google sign-in — try again"; } continue; } @@ -191,6 +182,26 @@ export class Pi extends Think { } } + // Google's MCP server answers initialize and tools/list anonymously, so + // a tokenless connection lands "ready" without OAuth ever starting — the + // 401 only appears on a real tool call. If the desk is connected but has + // no tokens (fresh toggle OR a connection left over from an earlier + // visit), force that call and surface the consent URL it produces. + if ( + enabled.has("gcal") && + this.isDesk() && + !authUrls.gcal && + !appErrors.gcal && + this.env.GOOGLE_OAUTH_CLIENT_ID && + this.env.GOOGLE_OAUTH_CLIENT_SECRET && + this.getMcpServers().servers.gcal && + !(await this.gcalTokensHas()) + ) { + const url = await this.forceGcalConsent(); + if (url) authUrls.gcal = url; + else appErrors.gcal = "couldn't start Google sign-in — try again"; + } + this.setState({ settings, appErrors, authUrls }); return { ok: true as const, appErrors, authUrls }; }