@saadnvd1/agent-os 0.3.664 → 0.3.671

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/app/api/audits/[kind]/[id]/route.test.ts +2 -2
  2. package/app/api/mcp-auth/callback/route.test.ts +42 -0
  3. package/app/api/mcp-auth/callback/route.ts +56 -0
  4. package/app/api/projects/init/route.test.ts +2 -4
  5. package/app/api/sessions/[id]/mcp-auth/route.test.ts +86 -0
  6. package/app/api/sessions/[id]/mcp-auth/route.ts +58 -0
  7. package/app/api/shares/route.test.ts +3 -4
  8. package/app/api/workspaces/[id]/route.ts +9 -1
  9. package/app/api/worktree-sweep/route.test.ts +21 -0
  10. package/app/api/worktree-sweep/route.ts +24 -4
  11. package/components/Chat/ChatPanel.tsx +6 -3
  12. package/components/Chat/McpServers.tsx +61 -24
  13. package/components/Chat/McpSignIn.tsx +122 -0
  14. package/components/Chat/session.ts +8 -0
  15. package/components/Health/SweepRuns.tsx +1 -0
  16. package/components/Workspaces/WorkspaceSettings.tsx +6 -0
  17. package/data/chat/mcpAuth.ts +42 -0
  18. package/data/workspaces/index.ts +6 -0
  19. package/lib/chat/driver.ts +24 -2
  20. package/lib/chat/drivers/claude-approvals.ts +7 -0
  21. package/lib/chat/drivers/claude-mcp-auth.ts +82 -0
  22. package/lib/chat/drivers/claude.ts +7 -0
  23. package/lib/chat/events.ts +12 -0
  24. package/lib/chat/mcp-auth.ts +193 -0
  25. package/lib/chat/registry.ts +2 -0
  26. package/lib/chat/runner.ts +18 -0
  27. package/lib/chat/worker/client.ts +35 -1
  28. package/lib/chat/worker/host.ts +9 -0
  29. package/lib/chat/worker/main.ts +1 -1
  30. package/lib/chat/worker/mcp-auth.ts +119 -0
  31. package/lib/chat/worker/protocol.ts +19 -0
  32. package/lib/db/migrations/20261018T1730_mcp_sign_ins.ts +22 -0
  33. package/lib/db/migrations/20261018T2100_orch_full_access.ts +16 -0
  34. package/lib/db/types.ts +3 -0
  35. package/lib/done/testing.ts +2 -2
  36. package/lib/env-setup.ts +8 -1
  37. package/lib/health/boots.ts +2 -8
  38. package/lib/orchestrator/armed.test-helpers.ts +3 -2
  39. package/lib/orchestrator/brief.ts +14 -9
  40. package/lib/orchestrator/external-gates.ts +62 -1
  41. package/lib/orchestrator/full-access.ts +21 -0
  42. package/lib/orchestrator/quick.ts +47 -4
  43. package/lib/orchestrator/serve.ts +11 -2
  44. package/lib/orchestrator/shell-guard.ts +23 -1
  45. package/lib/orchestrator/signoff.test-helpers.ts +3 -2
  46. package/lib/orchestrator/sweep-arm.ts +180 -0
  47. package/lib/orchestrator/tool-names.ts +4 -1
  48. package/lib/orchestrator/tool-schemas.ts +33 -1
  49. package/lib/orchestrator/tools.ts +6 -2
  50. package/lib/orchestrator/write-guard.ts +34 -7
  51. package/lib/project-config/brief.ts +3 -1
  52. package/lib/project-config/database.ts +6 -0
  53. package/lib/project-config/index.ts +108 -21
  54. package/lib/project-config/schema.ts +10 -4
  55. package/lib/project-config/session.ts +9 -6
  56. package/lib/security/net.ts +7 -4
  57. package/lib/tasks/move-testing.ts +2 -2
  58. package/lib/test-temp.ts +30 -0
  59. package/lib/workspaces.ts +6 -2
  60. package/lib/worktree-sweep/caches.ts +6 -0
  61. package/lib/worktree-sweep/extras.ts +16 -8
  62. package/lib/worktree-sweep/report.ts +1 -0
  63. package/lib/worktree-sweep/settings.ts +2 -2
  64. package/lib/worktree-sweep/testTemp.ts +113 -0
  65. package/lib/worktree-sweep/types.ts +2 -0
  66. package/package.json +1 -1
  67. package/scripts/autodeploy.test.ts +2 -2
  68. package/scripts/check-bundle.test.ts +2 -2
  69. package/scripts/hooks/banned-terms.test.ts +3 -2
  70. package/scripts/hooks/staged-secrets.test.ts +2 -2
  71. package/scripts/lib/build-swap.test.ts +2 -3
  72. package/scripts/lib/wsl.test.ts +2 -3
  73. package/scripts/update-run.test.ts +2 -2
@@ -0,0 +1,122 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ExternalLink } from "lucide-react";
5
+ import type { useMcpSignIn } from "@/data/chat/mcpAuth";
6
+
7
+ const LINK =
8
+ "bg-primary text-primary-foreground inline-flex h-11 items-center justify-center gap-2 rounded-lg px-4 text-sm font-medium md:h-9";
9
+ const GHOST =
10
+ "text-muted-foreground hover:text-foreground h-11 rounded-lg px-3 text-xs md:h-9";
11
+
12
+ // What happens after Sign in is pressed on one server: the link to open, and
13
+ // what to do if the provider can't return to AgentOS by itself. The list row
14
+ // turns connected on its own, over the chat socket, when it's done.
15
+ export function McpSignIn({
16
+ flow: { start, finish, recheck },
17
+ server,
18
+ onClose,
19
+ }: {
20
+ // Started by the Sign in press (not on mount: a dev double-mount would
21
+ // drop the request's result).
22
+ flow: ReturnType<typeof useMcpSignIn>;
23
+ server: string;
24
+ onClose: () => void;
25
+ }) {
26
+ const [pasted, setPasted] = useState("");
27
+
28
+ const mode = start.data?.mode;
29
+ const authUrl = start.data?.authUrl;
30
+ const error = start.error ?? finish.error ?? recheck.error;
31
+ const busy = start.isPending || finish.isPending || recheck.isPending;
32
+
33
+ return (
34
+ <div className="space-y-2 pt-1 pr-2 pb-2 pl-6.5 text-xs">
35
+ {start.isPending && (
36
+ <p className="text-muted-foreground">Starting sign-in…</p>
37
+ )}
38
+ {mode === "done" && (
39
+ <p className="text-muted-foreground">
40
+ Already signed in; reconnecting.
41
+ </p>
42
+ )}
43
+ {(mode === "callback" || mode === "paste" || mode === "external") &&
44
+ authUrl && (
45
+ <div className="flex flex-wrap items-center gap-2">
46
+ <a
47
+ href={authUrl}
48
+ target="_blank"
49
+ rel="noopener noreferrer"
50
+ className={LINK}
51
+ >
52
+ {mode === "external" ? "Open claude.ai" : "Open sign-in page"}
53
+ <ExternalLink className="size-4" />
54
+ </a>
55
+ {mode === "external" && (
56
+ <button
57
+ type="button"
58
+ disabled={busy}
59
+ onClick={() => recheck.mutate()}
60
+ className={GHOST}
61
+ >
62
+ Check again
63
+ </button>
64
+ )}
65
+ </div>
66
+ )}
67
+ {mode === "callback" && (
68
+ <p className="text-muted-foreground">
69
+ Approve it there. This list updates by itself.
70
+ </p>
71
+ )}
72
+ {mode === "external" && (
73
+ <p className="text-muted-foreground">
74
+ This server signs in on claude.ai. Finish there, then come back.
75
+ </p>
76
+ )}
77
+ {mode === "paste" && (
78
+ <>
79
+ <p className="text-muted-foreground">
80
+ This server only sends the sign-in back to the machine running the
81
+ session. On this device the page you land on won&apos;t load: copy
82
+ its address and paste it here.
83
+ </p>
84
+ <form
85
+ className="flex items-center gap-2"
86
+ onSubmit={(e) => {
87
+ e.preventDefault();
88
+ if (pasted.trim()) finish.mutate(pasted.trim());
89
+ }}
90
+ >
91
+ <input
92
+ value={pasted}
93
+ onChange={(e) => setPasted(e.target.value)}
94
+ inputMode="url"
95
+ autoComplete="off"
96
+ autoCapitalize="off"
97
+ spellCheck={false}
98
+ placeholder="Address of the page after approving"
99
+ aria-label={`Address after signing in to ${server}`}
100
+ className="bg-background hairline h-11 min-w-0 flex-1 rounded-lg px-3 text-base md:h-9 md:text-xs"
101
+ />
102
+ <button
103
+ type="submit"
104
+ disabled={busy || !pasted.trim()}
105
+ className="bg-secondary text-secondary-foreground hairline h-11 shrink-0 rounded-lg px-3 text-xs font-medium disabled:opacity-50 md:h-9"
106
+ >
107
+ Finish
108
+ </button>
109
+ </form>
110
+ </>
111
+ )}
112
+ {error && (
113
+ <p className="text-destructive whitespace-pre-wrap" role="alert">
114
+ {error.message}
115
+ </p>
116
+ )}
117
+ <button type="button" onClick={onClose} className={GHOST}>
118
+ {error ? "Close" : "Cancel"}
119
+ </button>
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,8 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+
5
+ // The session this chat belongs to, for rows that call its API (a context,
6
+ // not a prop through every item).
7
+ export const ChatSessionId = createContext<string | null>(null);
8
+ export const useChatSessionId = () => useContext(ChatSessionId);
@@ -17,6 +17,7 @@ const KIND: Record<string, string> = {
17
17
  database: "task database",
18
18
  spare: "dependency spare",
19
19
  temp: "temp folder",
20
+ "test-temp": "test temp folder",
20
21
  "derived-data": "Xcode build folder",
21
22
  };
22
23
 
@@ -55,6 +55,12 @@ export function WorkspaceSettings() {
55
55
  checked={w.wake_tasks_on_pr}
56
56
  onChange={(on) => update.mutate({ id: w.id, wakeTasksOnPR: on })}
57
57
  />
58
+ <SettingToggle
59
+ title="Orchestrator full access"
60
+ detail="Its shell and file writes aren't checked: it can push, commit, edit any repository or file on this machine, and merge a PR directly. Off: the deny list, write limits, quick-change cap and merge-only-through-the-gates apply."
61
+ checked={w.orch_full_access}
62
+ onChange={(on) => update.mutate({ id: w.id, orchFullAccess: on })}
63
+ />
58
64
  </section>
59
65
  ))}
60
66
  <FreshStartForm />
@@ -0,0 +1,42 @@
1
+ import { useMutation } from "@tanstack/react-query";
2
+ import type { McpSignInStart } from "@/lib/chat/events";
3
+
4
+ type Body = {
5
+ action: "start" | "submit" | "reconnect";
6
+ server: string;
7
+ itemId: string;
8
+ url?: string;
9
+ };
10
+
11
+ async function post<T>(sessionId: string, body: Body): Promise<T> {
12
+ const res = await fetch(`/api/sessions/${sessionId}/mcp-auth`, {
13
+ method: "POST",
14
+ headers: { "Content-Type": "application/json" },
15
+ body: JSON.stringify(body),
16
+ });
17
+ const data = await res.json().catch(() => ({}));
18
+ if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
19
+ return data as T;
20
+ }
21
+
22
+ // Sign in to one MCP server of a chat's /mcp list. The list itself updates
23
+ // over the chat socket once the server is back.
24
+ export function useMcpSignIn(
25
+ sessionId: string,
26
+ itemId: string,
27
+ server: string
28
+ ) {
29
+ const start = useMutation({
30
+ mutationFn: () =>
31
+ post<McpSignInStart>(sessionId, { action: "start", server, itemId }),
32
+ });
33
+ const finish = useMutation({
34
+ mutationFn: (url: string) =>
35
+ post<{ ok: true }>(sessionId, { action: "submit", server, itemId, url }),
36
+ });
37
+ const recheck = useMutation({
38
+ mutationFn: () =>
39
+ post<{ ok: true }>(sessionId, { action: "reconnect", server, itemId }),
40
+ });
41
+ return { start, finish, recheck };
42
+ }
@@ -66,6 +66,8 @@ export function useUpdateWorkspace() {
66
66
  maxRunningTasks?: number | null;
67
67
  // Message tasks about failing checks and reviews on their PRs.
68
68
  wakeTasksOnPR?: boolean;
69
+ // Its orchestrator runs without guards.
70
+ orchFullAccess?: boolean;
69
71
  // MCP servers its orchestrator may use, by name.
70
72
  mcpServers?: string[];
71
73
  }) =>
@@ -80,6 +82,7 @@ export function useUpdateWorkspace() {
80
82
  id,
81
83
  maxRunningTasks,
82
84
  wakeTasksOnPR,
85
+ orchFullAccess,
83
86
  mcpServers,
84
87
  ...updates
85
88
  }) => {
@@ -96,6 +99,9 @@ export function useUpdateWorkspace() {
96
99
  ...(wakeTasksOnPR !== undefined && {
97
100
  wake_tasks_on_pr: wakeTasksOnPR,
98
101
  }),
102
+ ...(orchFullAccess !== undefined && {
103
+ orch_full_access: orchFullAccess,
104
+ }),
99
105
  ...(mcpServers !== undefined && {
100
106
  orch_mcp_servers: mcpServers,
101
107
  }),
@@ -11,9 +11,26 @@ import type {
11
11
  ChatModel,
12
12
  DriverEvent,
13
13
  FileSuggestion,
14
+ McpServerView,
15
+ McpSignInStart,
14
16
  UndoPreview,
15
17
  } from "./events";
16
18
 
19
+ // An agent's way of signing in to an MCP server that needs it, without
20
+ // restarting the conversation. Tokens stay with the agent; the host only
21
+ // sees the link to open and, later, the address the provider returned to.
22
+ export interface McpAuth {
23
+ // `state` ties the provider's return to this attempt; it stays server-side.
24
+ start(
25
+ server: string,
26
+ redirectUri?: string
27
+ ): Promise<McpSignInStart & { state?: string }>;
28
+ // Hands over the address the provider returned the browser to.
29
+ submit(server: string, callbackUrl: string): Promise<void>;
30
+ reconnect(server: string): Promise<void>;
31
+ servers(): Promise<McpServerView[]>;
32
+ }
33
+
17
34
  export interface ChatStartOptions {
18
35
  cwd: string;
19
36
  model: string;
@@ -39,7 +56,8 @@ export interface ChatStartOptions {
39
56
  // can't be changed for this conversation.
40
57
  permissionMode?: PermissionMode;
41
58
  // Checked before every tool call, whatever the permissions say: why it's
42
- // refused, or null to let it go on to them.
59
+ // refused, null to let it go on to them, or a warning the agent is shown
60
+ // while the call still goes on.
43
61
  toolGuard?: ToolGuard;
44
62
  // The note handed back to the agent right after its context is compacted
45
63
  // (lib/chat/compact-context), empty for none. Only Claude Code has a hook
@@ -47,10 +65,12 @@ export interface ChatStartOptions {
47
65
  compactContext?: () => Promise<string>;
48
66
  }
49
67
 
68
+ export type ToolGuardResult = string | null | { warning: string };
69
+
50
70
  export type ToolGuard = (
51
71
  tool: string,
52
72
  input: Record<string, unknown>
53
- ) => Promise<string | null>;
73
+ ) => Promise<ToolGuardResult>;
54
74
 
55
75
  // One live conversation with an agent. Messages sent while a turn runs are
56
76
  // queued by the provider and folded into the conversation.
@@ -88,6 +108,8 @@ export interface ChatConversation {
88
108
  backgroundTask?(toolUseId?: string): Promise<boolean>;
89
109
  // Puts files back as they were before a message (dryRun: only say what).
90
110
  undo(checkpoint: string, dryRun: boolean): Promise<UndoResult>;
111
+ // Signing in to an MCP server that needs it (Claude's).
112
+ mcpAuth?: McpAuth;
91
113
  close(): void;
92
114
  events: AsyncIterable<DriverEvent>;
93
115
  }
@@ -248,6 +248,13 @@ export function guardHook(guard: ToolGuard): HookCallback {
248
248
  (input.tool_input ?? {}) as Record<string, unknown>
249
249
  ).catch((e: Error) => `Couldn't check this call: ${e.message}`);
250
250
  if (reason === null) return {};
251
+ if (typeof reason !== "string")
252
+ return {
253
+ hookSpecificOutput: {
254
+ hookEventName: "PreToolUse",
255
+ additionalContext: reason.warning,
256
+ },
257
+ };
251
258
  return {
252
259
  hookSpecificOutput: {
253
260
  hookEventName: "PreToolUse",
@@ -0,0 +1,82 @@
1
+ import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk";
2
+ import type { McpAuth } from "../driver";
3
+ import type { McpServerView, McpSignInStart } from "../events";
4
+
5
+ // The CLI takes these as control requests and the SDK's Query has the
6
+ // methods, but its type declarations don't list them.
7
+ type AuthQuery = Query & {
8
+ mcpAuthenticate?(server: string, redirectUri?: string): Promise<unknown>;
9
+ mcpSubmitOAuthCallbackUrl?(server: string, url: string): Promise<unknown>;
10
+ };
11
+
12
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
13
+
14
+ // Only a link a person can safely open: https, or http to this machine.
15
+ export function safeAuthUrl(value: unknown): string | null {
16
+ if (typeof value !== "string") return null;
17
+ try {
18
+ const url = new URL(value);
19
+ if (url.protocol === "https:") return url.href;
20
+ if (url.protocol === "http:" && LOOPBACK.has(url.hostname)) return url.href;
21
+ } catch {
22
+ // Not a URL.
23
+ }
24
+ return null;
25
+ }
26
+
27
+ // The CLI's answer to mcp_authenticate, as what the UI needs.
28
+ export function toSignInStart(
29
+ response: unknown
30
+ ): McpSignInStart & { state?: string } {
31
+ const r = (response ?? {}) as {
32
+ authUrl?: unknown;
33
+ requiresUserAction?: unknown;
34
+ callbackExpected?: unknown;
35
+ redirectScheme?: unknown;
36
+ state?: unknown;
37
+ };
38
+ if (!r.requiresUserAction && r.authUrl === undefined) return { mode: "done" };
39
+ const authUrl = safeAuthUrl(r.authUrl);
40
+ if (!authUrl)
41
+ throw new Error("The server gave a sign-in link AgentOS won't open");
42
+ if (!r.callbackExpected) return { mode: "external", authUrl };
43
+ const state = typeof r.state === "string" ? r.state : undefined;
44
+ return r.redirectScheme === "custom"
45
+ ? { mode: "callback", authUrl, state }
46
+ : { mode: "paste", authUrl, state };
47
+ }
48
+
49
+ // Whether a server can be signed in to: it speaks OAuth over HTTP.
50
+ export function canSignIn(s: McpServerStatus): boolean {
51
+ if (s.status !== "needs-auth") return false;
52
+ const c = s.config as { type?: string; command?: unknown } | undefined;
53
+ if (!c) return true;
54
+ return c.type === "http" || c.type === "sse" || c.type === "claudeai-proxy";
55
+ }
56
+
57
+ export function claudeMcpAuth(
58
+ q: Query,
59
+ view: (s: McpServerStatus) => McpServerView
60
+ ): McpAuth {
61
+ const auth = q as AuthQuery;
62
+ return {
63
+ async start(server, redirectUri) {
64
+ if (!auth.mcpAuthenticate)
65
+ throw new Error(
66
+ "This Claude Code version can't sign in to MCP servers from AgentOS"
67
+ );
68
+ return toSignInStart(await auth.mcpAuthenticate(server, redirectUri));
69
+ },
70
+ async submit(server, callbackUrl) {
71
+ if (!auth.mcpSubmitOAuthCallbackUrl)
72
+ throw new Error(
73
+ "This Claude Code version can't sign in to MCP servers from AgentOS"
74
+ );
75
+ await auth.mcpSubmitOAuthCallbackUrl(server, callbackUrl);
76
+ },
77
+ reconnect: (server) => q.reconnectMcpServer(server),
78
+ async servers() {
79
+ return (await q.mcpServerStatus()).map(view);
80
+ },
81
+ };
82
+ }
@@ -29,6 +29,7 @@ import { NO_ATTRIBUTION } from "../../agents/attribution";
29
29
  import { SUBAGENTS_ENV } from "../../models/subagent-env";
30
30
  import { parseSubagentEnv, subagentModelHook } from "../../models/subagents";
31
31
  import { callFromAssistant } from "../../usage/calls";
32
+ import { canSignIn, claudeMcpAuth } from "./claude-mcp-auth";
32
33
 
33
34
  // Claude Code through the Agent SDK, signed in with the user's own Claude
34
35
  // Code login. Asks for approval through chat cards unless given full access,
@@ -71,6 +72,10 @@ function cleanError(text?: string): string | undefined {
71
72
  return line.length > 300 ? `${line.slice(0, 299)}…` : line;
72
73
  }
73
74
 
75
+ export function cleanMcpError(text?: string): string | undefined {
76
+ return cleanError(text);
77
+ }
78
+
74
79
  export function mcpView(s: McpServerStatus): McpServerView {
75
80
  return {
76
81
  name: s.name,
@@ -78,6 +83,7 @@ export function mcpView(s: McpServerStatus): McpServerView {
78
83
  scope: s.source ?? s.scope,
79
84
  version: s.serverInfo?.version,
80
85
  error: cleanError(s.error),
86
+ ...(canSignIn(s) && { signIn: true }),
81
87
  tools: (s.tools ?? []).map((t) => ({
82
88
  name: t.name,
83
89
  description: firstLine(t.description),
@@ -346,6 +352,7 @@ export const claudeDriver: ChatDriver = {
346
352
  },
347
353
  ]);
348
354
  },
355
+ mcpAuth: claudeMcpAuth(q, mcpView),
349
356
  async interrupt() {
350
357
  await q.interrupt();
351
358
  },
@@ -104,9 +104,21 @@ export interface McpServerView {
104
104
  scope?: string;
105
105
  version?: string;
106
106
  error?: string;
107
+ // Needs sign-in and AgentOS can start it (the driver can, and the server
108
+ // speaks OAuth over HTTP). Decided by the server, never by its name.
109
+ signIn?: boolean;
107
110
  tools: { name: string; description?: string }[];
108
111
  }
109
112
 
113
+ // What starting an MCP server's sign-in came back with: "callback" (open the
114
+ // link; the provider returns to AgentOS), "paste" (the provider only returns
115
+ // to the machine running the session: paste the address it ends on),
116
+ // "external" (finish on claude.ai) or "done" (signed in without asking).
117
+ export interface McpSignInStart {
118
+ mode: "callback" | "paste" | "external" | "done";
119
+ authUrl?: string;
120
+ }
121
+
110
122
  // How full the conversation's context window is, as the agent last measured.
111
123
  export interface ChatContext {
112
124
  usedTokens: number;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Signing in to an MCP server from the browser. The agent starts the
3
+ * sign-in and gives a link; the provider then sends the browser back to
4
+ * AgentOS (/api/mcp-auth/callback), which hands that address to the agent.
5
+ * What ties the return to the attempt is the OAuth `state`, kept in SQLite
6
+ * (the start and callback routes are separate bundles, so memory isn't
7
+ * shared), used once, and dropped after PENDING_MS. The agent holds the
8
+ * tokens; nothing here stores or logs the address it was given.
9
+ */
10
+
11
+ import { db } from "../db";
12
+ import { getItem } from "./store";
13
+ import { mcpAuthWorker } from "./runner";
14
+ import type { McpSignInStart } from "./events";
15
+
16
+ export const PENDING_MS = 15 * 60 * 1000;
17
+ export const CALLBACK_PATH = "/api/mcp-auth/callback";
18
+
19
+ interface Pending {
20
+ sessionId: string;
21
+ itemId: string;
22
+ server: string;
23
+ origin: string | null;
24
+ at: number;
25
+ }
26
+
27
+ interface PendingRow {
28
+ session_id: string;
29
+ item_id: string;
30
+ server: string;
31
+ origin: string | null;
32
+ at: number;
33
+ }
34
+
35
+ export function resetMcpSignIns(): void {
36
+ db.prepare(`DELETE FROM mcp_sign_ins`).run();
37
+ }
38
+
39
+ function sweep(now = Date.now()): void {
40
+ db.prepare(`DELETE FROM mcp_sign_ins WHERE at < ?`).run(now - PENDING_MS);
41
+ }
42
+
43
+ function savePending(state: string, p: Pending): void {
44
+ db.prepare(
45
+ `DELETE FROM mcp_sign_ins WHERE session_id = ? AND server = ?`
46
+ ).run(p.sessionId, p.server);
47
+ db.prepare(
48
+ `INSERT OR REPLACE INTO mcp_sign_ins (state, session_id, item_id, server, origin, at)
49
+ VALUES (?, ?, ?, ?, ?, ?)`
50
+ ).run(state, p.sessionId, p.itemId, p.server, p.origin, p.at);
51
+ }
52
+
53
+ function peekPending(state: string): Pending | undefined {
54
+ const r = db
55
+ .prepare(`SELECT * FROM mcp_sign_ins WHERE state = ?`)
56
+ .get(state) as PendingRow | undefined;
57
+ return (
58
+ r && {
59
+ sessionId: r.session_id,
60
+ itemId: r.item_id,
61
+ server: r.server,
62
+ origin: r.origin,
63
+ at: r.at,
64
+ }
65
+ );
66
+ }
67
+
68
+ // Single use: only the caller whose delete changed a row may go on.
69
+ function takePending(state: string): boolean {
70
+ return (
71
+ db.prepare(`DELETE FROM mcp_sign_ins WHERE state = ?`).run(state).changes >
72
+ 0
73
+ );
74
+ }
75
+
76
+ // `http(s)://host[:port]`, nothing else: the browser's own address for
77
+ // AgentOS, so the provider returns to wherever the user is.
78
+ export function cleanOrigin(value: string | null | undefined): string | null {
79
+ if (!value) return null;
80
+ try {
81
+ const url = new URL(value);
82
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
83
+ return url.origin === value ? url.origin : null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ function serverOf(sessionId: string, itemId: string, server: string) {
90
+ const item = getItem(sessionId, itemId);
91
+ if (item?.kind !== "mcp") throw new Error("That list is gone");
92
+ const view = item.servers.find((s) => s.name === server);
93
+ if (!view) throw new Error(`${server} isn't in that list`);
94
+ return view;
95
+ }
96
+
97
+ export async function startMcpSignIn(
98
+ sessionId: string,
99
+ input: { itemId: string; server: string; origin?: string | null }
100
+ ): Promise<McpSignInStart> {
101
+ if (!serverOf(sessionId, input.itemId, input.server).signIn)
102
+ throw new Error("AgentOS can't sign in to this server");
103
+ const worker = mcpAuthWorker(sessionId);
104
+ const origin = cleanOrigin(input.origin);
105
+ const { start, error } = await worker.mcpAuth(
106
+ {
107
+ action: "start",
108
+ server: input.server,
109
+ itemId: input.itemId,
110
+ redirectUri: origin ? `${origin}${CALLBACK_PATH}` : undefined,
111
+ },
112
+ 30_000
113
+ );
114
+ if (error || !start) throw new Error(error ?? "Sign-in didn't start");
115
+ sweep();
116
+ const { state, ...result } = start;
117
+ if (state)
118
+ savePending(state, {
119
+ sessionId,
120
+ itemId: input.itemId,
121
+ server: input.server,
122
+ origin,
123
+ at: Date.now(),
124
+ });
125
+ return result;
126
+ }
127
+
128
+ // The provider returned the browser (or the user pasted the address it
129
+ // ended on). `expect` pins a pasted address to the row it was typed in.
130
+ export async function finishMcpSignIn(
131
+ callbackUrl: string,
132
+ expect?: { sessionId: string; server: string }
133
+ ): Promise<{ server: string }> {
134
+ sweep();
135
+ let state: string | null = null;
136
+ try {
137
+ state = new URL(callbackUrl).searchParams.get("state");
138
+ } catch {
139
+ throw new Error("That isn't a web address");
140
+ }
141
+ const p = state ? peekPending(state) : undefined;
142
+ if (!state || !p)
143
+ throw new Error(
144
+ "That sign-in expired or was already used. Press Sign in again."
145
+ );
146
+ if (
147
+ expect &&
148
+ (expect.sessionId !== p.sessionId || expect.server !== p.server)
149
+ )
150
+ throw new Error("That address belongs to a different sign-in");
151
+ if (!takePending(state))
152
+ throw new Error(
153
+ "That sign-in expired or was already used. Press Sign in again."
154
+ );
155
+ const { error } = await mcpAuthWorker(p.sessionId).mcpAuth({
156
+ action: "submit",
157
+ server: p.server,
158
+ itemId: p.itemId,
159
+ url: callbackUrl,
160
+ });
161
+ if (error) throw new Error(error);
162
+ return { server: p.server };
163
+ }
164
+
165
+ // The provider's redirect reached our callback route. Rebuild the address
166
+ // from the origin the sign-in started with, not from the request (behind the
167
+ // custom server Next may derive it from Host), so it matches the
168
+ // redirect_uri the provider was given exactly.
169
+ export async function finishMcpSignInFromRedirect(
170
+ search: string
171
+ ): Promise<{ server: string }> {
172
+ sweep();
173
+ const state = new URLSearchParams(search).get("state");
174
+ const origin = state ? peekPending(state)?.origin : null;
175
+ if (!origin)
176
+ throw new Error(
177
+ "That sign-in expired or was already used. Press Sign in again."
178
+ );
179
+ return finishMcpSignIn(`${origin}${CALLBACK_PATH}${search}`);
180
+ }
181
+
182
+ // "Check again": reconnect and refresh the list.
183
+ export async function reconnectMcpServer(
184
+ sessionId: string,
185
+ input: { itemId: string; server: string }
186
+ ): Promise<void> {
187
+ serverOf(sessionId, input.itemId, input.server);
188
+ const { error } = await mcpAuthWorker(sessionId).mcpAuth(
189
+ { action: "reconnect", ...input },
190
+ 30_000
191
+ );
192
+ if (error) throw new Error(error);
193
+ }
@@ -38,6 +38,8 @@ export interface Live {
38
38
  canFresh?: boolean;
39
39
  // Whether it takes "background_task" (an older worker drops it).
40
40
  canBackground?: boolean;
41
+ // Whether it takes "mcp_auth" (sign in to an MCP server).
42
+ canMcpAuth?: boolean;
41
43
  // From an older build and idle: retired once its guess at the next
42
44
  // message is in, or this fires.
43
45
  retiring?: NodeJS.Timeout;
@@ -87,6 +87,7 @@ import {
87
87
  } from "./worker/client";
88
88
  import { buildId, isStaleWorker } from "../build";
89
89
  import type { WorkerEvent } from "./worker/protocol";
90
+ import type { WorkerClient } from "./worker/client";
90
91
  import { DEMO_REFUSAL, demoMode } from "../security/demo";
91
92
  import { chatHold, chatRefusal } from "./hold";
92
93
  import { getHost } from "../hosts";
@@ -363,6 +364,7 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
363
364
  canQueue: !!hello.caps?.includes("queue"),
364
365
  canFresh: !!hello.caps?.includes("fresh"),
365
366
  canBackground: !!hello.caps?.includes("background"),
367
+ canMcpAuth: !!hello.caps?.includes("mcp_auth"),
366
368
  };
367
369
  // From an older build and busy, or running background work: it goes at
368
370
  // its next turn boundary, by itself if it can. Messages meanwhile wait
@@ -932,6 +934,22 @@ export async function backgroundChatTask(
932
934
  });
933
935
  }
934
936
 
937
+ // The session's running worker, when it can sign in to MCP servers; why not
938
+ // otherwise (a chat on another machine, an idle one, one from an older build).
939
+ export function mcpAuthWorker(sessionId: string): WorkerClient {
940
+ checkChattable(getSession(sessionId));
941
+ const live = registry.live.get(sessionId);
942
+ if (!live || live.retiring)
943
+ throw new Error(
944
+ "This chat's agent isn't running. Send /mcp again to wake it, then sign in."
945
+ );
946
+ if (!live.canMcpAuth)
947
+ throw new Error(
948
+ "This chat's agent is from an older AgentOS build. Send it a message or reopen the chat, then try again."
949
+ );
950
+ return live.worker;
951
+ }
952
+
935
953
  export function chatTaskOutput(
936
954
  sessionId: string,
937
955
  taskId: string