@saadnvd1/agent-os 0.3.495 → 0.3.497

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.
package/README.md CHANGED
@@ -1081,7 +1081,7 @@ it may do on its own, and the lines that always come back to you as asks.
1081
1081
  ALTER or ATTACH; files under `~/.agent-os` outside its folder), stopping
1082
1082
  AgentOS or its tmux (`pkill`/`kill` aimed at it, `tmux kill-*`, a
1083
1083
  `launchctl` or `serviceman` restart), and reading secret files (the same
1084
- list as the write guard) or tokenvault (`tv get`, `tv dump`). It reads each
1084
+ list as the write guard; tokenvault is allowed, through `tv` and its folders). It reads each
1085
1085
  command line the way a shell would, so these are caught inside pipes,
1086
1086
  chains and `$( )`, behind `bash -c`, `sh -c`, `env`, `sudo`, `xargs`,
1087
1087
  `find -exec` or an absolute path, and after a `cd` or `git -C`. **The deny
@@ -1113,6 +1113,15 @@ it may do on its own, and the lines that always come back to you as asks.
1113
1113
  written, so an orchestrator misled by text it read could run code that way.
1114
1114
  That's accepted: task agents can already write anywhere, so an allowlist
1115
1115
  on the orchestrator alone would buy little.
1116
+ - **MCP servers.** An orchestrator runs without prompts, so an MCP server's
1117
+ tools are refused unless its workspace allows the server. Settings →
1118
+ Workspaces lists the servers configured on the machine (names only: user
1119
+ servers, connectors and plugin servers; never their env, headers or tokens)
1120
+ as toggles, and `linear` is allowed by default. A ticked server adds a bare
1121
+ `mcp__<server>` rule to the orchestrator's allowed tools, covering all its
1122
+ tools. Its worker ends at its next turn boundary when the list changes, and
1123
+ the next one starts with the new permissions. Sending a message, email or
1124
+ post through a server stays a hard line the brief sends to `ask_saad`.
1116
1125
  Its chat worker checks every write in a PreToolUse hook before it runs, on
1117
1126
  the path with symlinks and `..` resolved as the system will, and refuses
1118
1127
  with the reason; the repositories are read from the database on each write,
@@ -0,0 +1,8 @@
1
+ import { NextResponse } from "next/server";
2
+ import { configuredMcpServers } from "@/lib/orchestrator/mcp-servers";
3
+
4
+ // The names of the MCP servers configured on this machine, for choosing which
5
+ // a workspace's orchestrator may use. Names only.
6
+ export async function GET() {
7
+ return NextResponse.json({ servers: configuredMcpServers() });
8
+ }
@@ -1,12 +1,19 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { deleteWorkspace, updateWorkspace } from "@/lib/workspaces";
3
+ import { applyMcpServers } from "@/lib/orchestrator/mcp-servers";
3
4
 
4
5
  type RouteParams = { params: Promise<{ id: string }> };
5
6
 
6
7
  export async function PATCH(request: NextRequest, { params }: RouteParams) {
7
8
  const { id } = await params;
8
- const { name, collapsed, sortOrder, maxRunningTasks, wakeTasksOnPR } =
9
- await request.json();
9
+ const {
10
+ name,
11
+ collapsed,
12
+ sortOrder,
13
+ maxRunningTasks,
14
+ wakeTasksOnPR,
15
+ mcpServers,
16
+ } = await request.json();
10
17
  if (wakeTasksOnPR !== undefined && typeof wakeTasksOnPR !== "boolean")
11
18
  return NextResponse.json(
12
19
  { error: "wakeTasksOnPR is true or false" },
@@ -20,6 +27,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
20
27
  sortOrder,
21
28
  maxRunningTasks,
22
29
  wakeTasksOnPR,
30
+ mcpServers,
23
31
  });
24
32
  } catch (error) {
25
33
  const message = error instanceof Error ? error.message : String(error);
@@ -28,6 +36,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
28
36
  if (!workspace) {
29
37
  return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
30
38
  }
39
+ if (mcpServers !== undefined) await applyMcpServers(id);
31
40
  return NextResponse.json({ workspace });
32
41
  }
33
42
 
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import { Switch } from "@/components/ui/switch";
4
+ import { useMcpServersQuery, useUpdateWorkspace } from "@/data/workspaces";
5
+ import type { Workspace } from "@/lib/db";
6
+
7
+ // Which MCP servers a workspace's orchestrator may use. Servers carry their
8
+ // own auth, so none is allowed unless ticked here.
9
+ export function McpServersForm({ workspace }: { workspace: Workspace }) {
10
+ const { data: configured = [] } = useMcpServersQuery();
11
+ const update = useUpdateWorkspace();
12
+ const allowed = workspace.orch_mcp_servers;
13
+ // A ticked server no longer configured stays listed, so it can be unticked.
14
+ const names = [...new Set([...configured, ...allowed])];
15
+
16
+ const toggle = (name: string, on: boolean) =>
17
+ update.mutate({
18
+ id: workspace.id,
19
+ mcpServers: on ? [...allowed, name] : allowed.filter((n) => n !== name),
20
+ });
21
+
22
+ return (
23
+ <div className="bg-muted/40 space-y-2 rounded-lg px-3 py-2.5">
24
+ <div>
25
+ <p className="text-sm font-medium">
26
+ MCP servers its orchestrator may use
27
+ </p>
28
+ <p className="text-muted-foreground text-xs">
29
+ All of a ticked server&apos;s tools are allowed. Sending a message,
30
+ email or post through one still goes to you as an ask. Takes effect at
31
+ its next turn.
32
+ </p>
33
+ </div>
34
+ {names.length === 0 ? (
35
+ <p className="text-muted-foreground text-xs">
36
+ No MCP servers are configured on this machine.
37
+ </p>
38
+ ) : (
39
+ names.map((name) => (
40
+ <label
41
+ key={name}
42
+ className="flex min-h-11 items-center justify-between gap-3 text-sm"
43
+ >
44
+ <span className="min-w-0 truncate">{name}</span>
45
+ <Switch
46
+ checked={allowed.includes(name)}
47
+ onCheckedChange={(on) => toggle(name, on)}
48
+ />
49
+ </label>
50
+ ))
51
+ )}
52
+ </div>
53
+ );
54
+ }
@@ -5,6 +5,7 @@ import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
5
5
  import { TaskLimitForm } from "./TaskLimitForm";
6
6
  import { RulesForm } from "./RulesForm";
7
7
  import { FreshStartForm } from "./FreshStartForm";
8
+ import { McpServersForm } from "./McpServersForm";
8
9
  import { StallForm } from "./StallForm";
9
10
  import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
10
11
  import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
@@ -45,6 +46,7 @@ export function WorkspaceSettings() {
45
46
  />
46
47
  <RulesForm workspaceId={w.id} />
47
48
  <WorkspaceModelsForm workspaceId={w.id} />
49
+ <McpServersForm workspace={w} />
48
50
  <SettingToggle
49
51
  title="Wake tasks on CI failures and reviews"
50
52
  detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
@@ -23,6 +23,17 @@ export function useWorkspacesQuery() {
23
23
  });
24
24
  }
25
25
 
26
+ // The MCP servers configured on this machine (names only).
27
+ export function useMcpServersQuery() {
28
+ return useQuery({
29
+ queryKey: [...workspaceKeys.all, "mcp-servers"],
30
+ queryFn: async () =>
31
+ (await json<{ servers: string[] }>(await fetch("/api/mcp-servers")))
32
+ .servers,
33
+ staleTime: 60000,
34
+ });
35
+ }
36
+
26
37
  export function useCreateWorkspace() {
27
38
  const queryClient = useQueryClient();
28
39
  return useMutation({
@@ -55,6 +66,8 @@ export function useUpdateWorkspace() {
55
66
  maxRunningTasks?: number | null;
56
67
  // Message tasks about failing checks and reviews on their PRs.
57
68
  wakeTasksOnPR?: boolean;
69
+ // MCP servers its orchestrator may use, by name.
70
+ mcpServers?: string[];
58
71
  }) =>
59
72
  json(
60
73
  await fetch(`/api/workspaces/${id}`, {
@@ -63,7 +76,13 @@ export function useUpdateWorkspace() {
63
76
  body: JSON.stringify(updates),
64
77
  })
65
78
  ),
66
- onMutate: async ({ id, maxRunningTasks, wakeTasksOnPR, ...updates }) => {
79
+ onMutate: async ({
80
+ id,
81
+ maxRunningTasks,
82
+ wakeTasksOnPR,
83
+ mcpServers,
84
+ ...updates
85
+ }) => {
67
86
  await queryClient.cancelQueries({ queryKey: workspaceKeys.list() });
68
87
  queryClient.setQueryData<Workspace[]>(workspaceKeys.list(), (prev) =>
69
88
  prev?.map((w) =>
@@ -77,6 +96,9 @@ export function useUpdateWorkspace() {
77
96
  ...(wakeTasksOnPR !== undefined && {
78
97
  wake_tasks_on_pr: wakeTasksOnPR,
79
98
  }),
99
+ ...(mcpServers !== undefined && {
100
+ orch_mcp_servers: mcpServers,
101
+ }),
80
102
  }
81
103
  : w
82
104
  )
@@ -901,3 +901,17 @@ export async function reattachChats(): Promise<void> {
901
901
  for (const id of queuedSessions(Date.now() - RESUME_WITHIN_MS))
902
902
  if (!live.has(id)) await resumeQueue(id);
903
903
  }
904
+
905
+ // Asks a chat's worker to end at its next turn boundary, so the next message
906
+ // starts one that reads its settings afresh (an orchestrator's permissions,
907
+ // say). A worker already gone, or too old to understand, needs nothing.
908
+ export async function retireChat(sessionId: string): Promise<void> {
909
+ const live =
910
+ registry.live.get(sessionId) ??
911
+ (runningWorkers().includes(sessionId)
912
+ ? await ensureLive(sessionId, false).catch(() => undefined)
913
+ : undefined);
914
+ if (!live || live.retireSent) return;
915
+ live.worker.command({ type: "retire" });
916
+ live.retireSent = true;
917
+ }
@@ -1493,6 +1493,20 @@ const migrations: Migration[] = [
1493
1493
  add("task_queue", "started_by", "TEXT");
1494
1494
  },
1495
1495
  },
1496
+ {
1497
+ id: 66,
1498
+ name: "workspace_orch_mcp_servers",
1499
+ up: (db) => {
1500
+ // The MCP servers (by name) a workspace's orchestrator may use.
1501
+ const columns = db.prepare(`PRAGMA table_info(workspaces)`).all() as {
1502
+ name: string;
1503
+ }[];
1504
+ if (!columns.some((c) => c.name === "orch_mcp_servers"))
1505
+ db.exec(
1506
+ `ALTER TABLE workspaces ADD COLUMN orch_mcp_servers TEXT NOT NULL DEFAULT '["linear"]'`
1507
+ );
1508
+ },
1509
+ },
1496
1510
  ];
1497
1511
 
1498
1512
  // `upTo`: stop after this id (tests that start from an older database).
package/lib/db/types.ts CHANGED
@@ -220,6 +220,8 @@ export interface Workspace {
220
220
  orch_max_running: number;
221
221
  orch_max_starts_per_hour: number;
222
222
  orch_brake: string | null;
223
+ // MCP servers (by name) its orchestrator may use.
224
+ orch_mcp_servers: string[];
223
225
  // Set while Saad has the orchestrator paused.
224
226
  orch_paused_at: string | null;
225
227
  // Tasks running at once before new ones queue (lib/tasks/queue.ts); null: no limit.
@@ -124,7 +124,9 @@ Acting:
124
124
  - \`${TOOL_NAMES.disarm}\` (task): stop an armed task merging by itself.
125
125
  - \`${TOOL_NAMES.ask_saad}\` (title, detail, link?, kind, task?, sha?): park an item on Saad's asks list and carry on; it never waits.
126
126
 
127
- Act through these tools, not the shell: they're scoped to this workspace and braked. The shell runs anything without asking (Saad chose this on 2026-10-11), so use it to read and investigate yourself: \`gh pr list\`, \`gh run view --log-failed\`, \`gh api\` GETs, \`curl\`, \`sqlite3 -readonly\`, git in any repository, a skill's scripts, pipes and chains. Never use it to get around the tools. A deny list refuses, naming the tool to use instead: merging, closing or retargeting a PR (\`${TOOL_NAMES.sign_off}\`, \`${TOOL_NAMES.arm}\`, \`${TOOL_NAMES.land}\`, \`${TOOL_NAMES.drop}\`), \`git push\` (\`${TOOL_NAMES.quick_pr}\`), writing in a project repository or task worktree other than your own quick changes (\`${TOOL_NAMES.start_task}\`, \`${TOOL_NAMES.quick_change}\`), writing AgentOS's state (sqlite3 without \`-readonly\`, or files in ~/.agent-os outside your folder), stopping or restarting AgentOS or its tmux, and reading secret files or tokenvault. The list catches honest mistakes, not a determined bypass: a script or \`python -c\` could do any of these unseen, and doing so would break Saad's trust in every gate. \`aos notify "<text>"\` pushes a message to Saad's phone. Use it for what he'd want to see now and asked for: the morning report, a real milestone (a stack landed, a blocker only he can clear). Never for routine status: one a minute at most, and he reads the rest here. You can read files anywhere (Read, Grep, Glob), and write them (Write, Edit, NotebookEdit) anywhere except: a project repository or task worktree other than your own open quick changes' worktrees (start a task, or a quick change for a small edit, so the change goes through CI, review and scope), AgentOS's own state in ~/.agent-os outside your folder, secret files (.env, keys, ~/.ssh, ~/.aws, tokenvault and the like), and files that make something run: dotfiles and dot-folders at the top of your home (shell, git, Claude and tool config), ~/Library and /Library's launch agents, git's core.hooksPath folders, the folders on PATH, and any .git, .claude, .mcp.json, CLAUDE.md or AGENTS.md, which would hand you a shell or run code around the gates. Each write is checked before it runs, on the path with symlinks and \`..\` resolved; a refused one says why.
127
+ The MCP servers this workspace allows (Settings → Workspaces) are yours to use through their own tools, for reading and for additive, reversible changes. Anything outbound through them (sending a message, an email, a post) is a hard line: park it with \`ask_saad\`.
128
+
129
+ Act through these tools, not the shell: they're scoped to this workspace and braked. The shell runs anything without asking (Saad chose this on 2026-10-11), so use it to read and investigate yourself: \`gh pr list\`, \`gh run view --log-failed\`, \`gh api\` GETs, \`curl\`, \`sqlite3 -readonly\`, git in any repository, a skill's scripts, pipes and chains. Never use it to get around the tools. A deny list refuses, naming the tool to use instead: merging, closing or retargeting a PR (\`${TOOL_NAMES.sign_off}\`, \`${TOOL_NAMES.arm}\`, \`${TOOL_NAMES.land}\`, \`${TOOL_NAMES.drop}\`), \`git push\` (\`${TOOL_NAMES.quick_pr}\`), writing in a project repository or task worktree other than your own quick changes (\`${TOOL_NAMES.start_task}\`, \`${TOOL_NAMES.quick_change}\`), writing AgentOS's state (sqlite3 without \`-readonly\`, or files in ~/.agent-os outside your folder), stopping or restarting AgentOS or its tmux, and reading secret files (env files, keys, ~/.ssh, ~/.aws). Tokenvault is available through \`tv\`: use a token inside the command that needs it (e.g. \`FOO="\$(tv get <project> <desc>)" cmd\`), never print, echo or log a value, and never send one off the machine except to the service it's for. The list catches honest mistakes, not a determined bypass: a script or \`python -c\` could do any of these unseen, and doing so would break Saad's trust in every gate. \`aos notify "<text>"\` pushes a message to Saad's phone. Use it for what he'd want to see now and asked for: the morning report, a real milestone (a stack landed, a blocker only he can clear). Never for routine status: one a minute at most, and he reads the rest here. You can read files anywhere (Read, Grep, Glob), and write them (Write, Edit, NotebookEdit) anywhere except: a project repository or task worktree other than your own open quick changes' worktrees (start a task, or a quick change for a small edit, so the change goes through CI, review and scope), AgentOS's own state in ~/.agent-os outside your folder, secret files (.env, keys, ~/.ssh, ~/.aws, tokenvault and the like), and files that make something run: dotfiles and dot-folders at the top of your home (shell, git, Claude and tool config), ~/Library and /Library's launch agents, git's core.hooksPath folders, the folders on PATH, and any .git, .claude, .mcp.json, CLAUDE.md or AGENTS.md, which would hand you a shell or run code around the gates. Each write is checked before it runs, on the path with symlinks and \`..\` resolved; a refused one says why.
128
130
 
129
131
  ## Untrusted text
130
132
 
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The MCP servers an orchestrator may use. Its permissions are otherwise a
3
+ * fixed list (tool-names.ts); each workspace adds whole servers, by name, from
4
+ * Settings → Workspaces. Servers carry their own auth and some send outbound
5
+ * (a message, an email), so none is allowed unless named; the brief keeps
6
+ * those actions as hard lines for ask_saad.
7
+ *
8
+ * Only names are ever read from the Claude config: never env, headers, URLs
9
+ * or tokens.
10
+ */
11
+
12
+ import fs from "fs";
13
+ import os from "os";
14
+ import path from "path";
15
+ import { ORCHESTRATOR_PERMISSIONS } from "./tool-names";
16
+ import { getOrchestrator } from "./home";
17
+ import { getWorkspace } from "../workspaces";
18
+ import { retireChat } from "../chat/runner";
19
+
20
+ export const DEFAULT_MCP_SERVERS = ["linear"];
21
+
22
+ // How Claude Code names a server inside a tool name: anything outside
23
+ // letters, digits, "_" and "-" becomes "_".
24
+ export const toolPrefix = (server: string) =>
25
+ `mcp__${server.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
26
+
27
+ // The permissions for an orchestrator whose workspace allows these servers:
28
+ // a bare mcp__<server> rule allows all of that server's tools.
29
+ export function orchestratorPermissions(servers: string[]) {
30
+ const rules = [...new Set(servers.map((s) => s.trim()).filter(Boolean))].map(
31
+ toolPrefix
32
+ );
33
+ return {
34
+ ...ORCHESTRATOR_PERMISSIONS,
35
+ allowedTools: [...ORCHESTRATOR_PERMISSIONS.allowedTools, ...rules],
36
+ };
37
+ }
38
+
39
+ const claudeDir = () =>
40
+ process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
41
+
42
+ function readJson(file: string): unknown {
43
+ try {
44
+ return JSON.parse(fs.readFileSync(file, "utf8"));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ const keys = (value: unknown): string[] =>
51
+ value && typeof value === "object" && !Array.isArray(value)
52
+ ? Object.keys(value)
53
+ : [];
54
+
55
+ // Names of the servers the agent sees on this machine: user-level servers
56
+ // (~/.claude.json), connectors the account has connected, and the servers
57
+ // of installed plugins.
58
+ export function configuredMcpServers(): string[] {
59
+ const names = new Set<string>();
60
+ const user = readJson(
61
+ process.env.CLAUDE_CONFIG_DIR
62
+ ? path.join(claudeDir(), ".claude.json")
63
+ : path.join(os.homedir(), ".claude.json")
64
+ ) as Record<string, unknown> | null;
65
+ for (const name of keys(user?.mcpServers)) names.add(name);
66
+ const connectors = user?.claudeAiMcpEverConnected;
67
+ if (Array.isArray(connectors))
68
+ for (const c of connectors) if (typeof c === "string") names.add(c);
69
+
70
+ const installed = readJson(
71
+ path.join(claudeDir(), "plugins", "installed_plugins.json")
72
+ ) as { plugins?: Record<string, { installPath?: unknown }[]> } | null;
73
+ for (const [id, installs] of Object.entries(installed?.plugins ?? {})) {
74
+ const plugin = id.split("@")[0];
75
+ const dir = installs?.[0]?.installPath;
76
+ if (typeof dir !== "string") continue;
77
+ const file = readJson(path.join(dir, ".mcp.json")) as Record<
78
+ string,
79
+ unknown
80
+ > | null;
81
+ const servers = keys(file?.mcpServers).length ? file?.mcpServers : file;
82
+ for (const server of keys(servers)) names.add(`plugin:${plugin}:${server}`);
83
+ }
84
+ return [...names].sort((a, b) => a.localeCompare(b));
85
+ }
86
+
87
+ // A workspace's allowed servers changed: its orchestrator's worker ends at its
88
+ // next turn boundary and the next one starts with the new permissions.
89
+ export async function applyMcpServers(workspaceId: string): Promise<void> {
90
+ const orchestrator = getOrchestrator(workspaceId);
91
+ if (orchestrator && getWorkspace(workspaceId))
92
+ await retireChat(orchestrator.id).catch(() => {});
93
+ }
@@ -469,14 +469,13 @@ async function stopRefusal(
469
469
  return null;
470
470
  }
471
471
 
472
- const SECRET_MSG = `Refused: orchestrators never read secrets (env files, keys, credential stores, tokenvault). If a step needs one, ${T.ask_saad}.`;
472
+ const SECRET_MSG = `Refused: orchestrators never read secrets (env files, keys, credential stores). If a step needs one, ${T.ask_saad}.`;
473
+
474
+ // Orchestrators may read tokenvault's folders (and run `tv`); writing them
475
+ // is still refused by the write guard.
476
+ const TOKENVAULT_DIR = /(^|\/)tokenvault(\/|$)/;
473
477
 
474
478
  function secretRefusal(inv: Invocation, cwd: string): string | null {
475
- if (
476
- (inv.name === "tv" || inv.name === "tokenvault") &&
477
- /^(get|dump)$/.test(positionals(inv.args)[0] ?? "")
478
- )
479
- return SECRET_MSG;
480
479
  for (const w of [...inv.args, ...inv.cmd.reads]) {
481
480
  for (const p of [
482
481
  w,
@@ -487,7 +486,12 @@ function secretRefusal(inv: Invocation, cwd: string): string | null {
487
486
  /\//.test(p) ||
488
487
  p.startsWith(".") ||
489
488
  fs.existsSync(path.resolve(cwd, p));
490
- if (pathish && isSecretPath(p)) return SECRET_MSG;
489
+ if (
490
+ pathish &&
491
+ isSecretPath(p) &&
492
+ !TOKENVAULT_DIR.test(path.resolve(cwd, p))
493
+ )
494
+ return SECRET_MSG;
491
495
  }
492
496
  }
493
497
  return null;
@@ -118,8 +118,10 @@ export function orchestratorTools(
118
118
  // What the orchestrator's agent sees of its tools: names, descriptions,
119
119
  // arguments and permissions. A worker after a deploy tells the agent its
120
120
  // tools changed only when this did.
121
- export function orchestratorToolsDigest(): string {
122
- return toolsDigest(DESCRIPTIONS, TOOL_SHAPES, ORCHESTRATOR_PERMISSIONS);
121
+ export function orchestratorToolsDigest(
122
+ permissions: object = ORCHESTRATOR_PERMISSIONS
123
+ ): string {
124
+ return toolsDigest(DESCRIPTIONS, TOOL_SHAPES, permissions);
123
125
  }
124
126
 
125
127
  export function toolsDigest(
@@ -2,7 +2,9 @@ import type { Session } from "../db";
2
2
  import { agentEnv } from "../agents/launch";
3
3
  import { loadOrchestratorBrief } from "./brief";
4
4
  import { orchestratorToken } from "./home";
5
- import { ORCHESTRATOR_PERMISSIONS, ORCHESTRATOR_SERVER } from "./tool-names";
5
+ import { ORCHESTRATOR_SERVER } from "./tool-names";
6
+ import { orchestratorPermissions } from "./mcp-servers";
7
+ import { getWorkspace } from "../workspaces";
6
8
  import {
7
9
  httpToolCaller,
8
10
  orchestratorTools,
@@ -19,6 +21,9 @@ export async function orchestratorExtras(
19
21
  session: Session
20
22
  ): Promise<RoleExtras> {
21
23
  const baseUrl = agentEnv(session.id).AGENTOS_URL;
24
+ const permissions = orchestratorPermissions(
25
+ getWorkspace(session.workspace_id ?? "")?.orch_mcp_servers ?? []
26
+ );
22
27
  return {
23
28
  systemAppend: await loadOrchestratorBrief(session),
24
29
  mcpServers: {
@@ -30,9 +35,9 @@ export async function orchestratorExtras(
30
35
  )
31
36
  ),
32
37
  },
33
- ...ORCHESTRATOR_PERMISSIONS,
38
+ ...permissions,
34
39
  toolGuard: (tool, input) => orchestratorToolRefusal(session, tool, input),
35
- toolsDigest: orchestratorToolsDigest(),
40
+ toolsDigest: orchestratorToolsDigest(permissions),
36
41
  refresh: {
37
42
  atTokens: refreshSettings().orchestratorTokens,
38
43
  role: "orchestrator",
package/lib/workspaces.ts CHANGED
@@ -6,15 +6,39 @@
6
6
  import { randomUUID } from "crypto";
7
7
  import { db, type Workspace } from "./db";
8
8
 
9
- type WorkspaceRow = Omit<Workspace, "collapsed" | "wake_tasks_on_pr"> & {
9
+ type WorkspaceRow = Omit<
10
+ Workspace,
11
+ "collapsed" | "wake_tasks_on_pr" | "orch_mcp_servers"
12
+ > & {
10
13
  collapsed: number;
11
14
  wake_tasks_on_pr: number;
15
+ orch_mcp_servers: string;
12
16
  };
13
17
 
18
+ function parseServers(raw: string): string[] {
19
+ try {
20
+ const list = JSON.parse(raw);
21
+ return Array.isArray(list)
22
+ ? list.filter((n): n is string => typeof n === "string")
23
+ : [];
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+
29
+ // A list of server names: trimmed, deduplicated, none empty.
30
+ function serverNames(value: unknown, current: string[]): string[] {
31
+ if (value === undefined) return current;
32
+ if (!Array.isArray(value) || value.some((n) => typeof n !== "string"))
33
+ throw new Error("mcpServers is a list of server names");
34
+ return [...new Set(value.map((n: string) => n.trim()).filter(Boolean))];
35
+ }
36
+
14
37
  const toWorkspace = (row: WorkspaceRow): Workspace => ({
15
38
  ...row,
16
39
  collapsed: !!row.collapsed,
17
40
  wake_tasks_on_pr: !!row.wake_tasks_on_pr,
41
+ orch_mcp_servers: parseServers(row.orch_mcp_servers),
18
42
  });
19
43
 
20
44
  export function listWorkspaces(): Workspace[] {
@@ -62,19 +86,21 @@ export function updateWorkspace(
62
86
  sortOrder?: number;
63
87
  maxRunningTasks?: number | null;
64
88
  wakeTasksOnPR?: boolean;
89
+ mcpServers?: string[];
65
90
  }
66
91
  ): Workspace | null {
67
92
  const existing = getWorkspace(id);
68
93
  if (!existing) return null;
69
94
  const name = updates.name?.trim() || existing.name;
70
95
  db.prepare(
71
- `UPDATE workspaces SET name = ?, collapsed = ?, sort_order = ?, max_running_tasks = ?, wake_tasks_on_pr = ? WHERE id = ?`
96
+ `UPDATE workspaces SET name = ?, collapsed = ?, sort_order = ?, max_running_tasks = ?, wake_tasks_on_pr = ?, orch_mcp_servers = ? WHERE id = ?`
72
97
  ).run(
73
98
  name,
74
99
  (updates.collapsed ?? existing.collapsed) ? 1 : 0,
75
100
  updates.sortOrder ?? existing.sort_order,
76
101
  taskLimit(updates.maxRunningTasks, existing.max_running_tasks),
77
102
  (updates.wakeTasksOnPR ?? existing.wake_tasks_on_pr) ? 1 : 0,
103
+ JSON.stringify(serverNames(updates.mcpServers, existing.orch_mcp_servers)),
78
104
  id
79
105
  );
80
106
  return getWorkspace(id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.495",
3
+ "version": "0.3.497",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"