Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion app/src/client/pages/AppsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import {
PI_APPS,
type AppKey,
Expand All @@ -19,6 +19,13 @@ export function AppsPage({
const desk = useDesk(settings);
const [saving, setSaving] = useState(false);

// Reconcile connections on arrival so statuses and any pending Google
// consent link are current, not left over from the last visit.
useEffect(() => {
void desk.ensureSetup().catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [desk.settingsHash]);

async function apply(next: PiSettings) {
savePrefs(identity.netid, { apps: next.apps, model: next.model });
setSaving(true);
Expand Down
47 changes: 47 additions & 0 deletions app/src/server/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,15 @@ export class Pi extends Think<Env, PiState> {
});
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;
}
Expand Down Expand Up @@ -226,6 +235,44 @@ export class Pi extends Think<Env, PiState> {
return { ok: true as const, count: messages.length };
}

/**
* Kick Google's MCP server with a cheap authenticated-only call so the
* transport's OAuth machinery runs (discovery, state, PKCE) and hands us
* a consent URL. Returns null if calendar access unexpectedly works
* already or no URL could be produced.
*/
private async forceGcalConsent(): Promise<string | null> {
try {
await this.mcp.callTool({
serverId: "gcal",
name: "list_calendars",
arguments: {},
});
return null; // already authorized somehow — nothing to do
} catch {
// Expected: 401 → the transport started the authorization flow.
}
// The live connection's provider holds the freshly built consent URL.
const manager = this.mcp as unknown as {
mcpConnections?: Record<
string,
{ options?: { transport?: { authProvider?: { authUrl?: string } } } }
>;
};
const live =
manager.mcpConnections?.gcal?.options?.transport?.authProvider?.authUrl;
if (live) return live;
// Fallback: re-registering an existing server redeems a stored auth URL.
const retry = await this.addMcpServer("Google Calendar", GCAL_MCP_URL, {
id: "gcal",
callbackHost: this.appOrigin(),
callbackPath: GCAL_CALLBACK_PATH.slice(1),
transport: { type: "streamable-http" },
});
if (retry.state === "authenticating") return retry.authUrl;
return this.getMcpServers().servers.gcal?.auth_url ?? null;
}

/** The per-user desk instance is the token authority for Google OAuth. */
private isDesk(): boolean {
return this.name.endsWith("-desk");
Expand Down
Loading