preferences + mcp-client: resolve from main worktree and add global MCP config

Two related fixes surfaced from a real sf headless auto run in dr-repo.

1. Project preferences now resolve from the MAIN worktree, not the
   current linked worktree. SF's auto-mode creates a git worktree per
   milestone (`.sf/worktrees/M003/`). The old code called
   `projectPreferencesPath()` which used `process.cwd()` — the
   milestone worktree — so a pref change on main (service_tier,
   dynamic_routing, model config) never reached an in-flight milestone
   until the branch merged main. Observed concretely when disabling
   dynamic_routing had no effect until we merged main into the
   milestone branch.

   New `projectPrefsRoot()` detects a linked worktree by reading
   `.git` (a FILE in worktrees, pointing to
   `/main/.git/worktrees/NAME`), follows the `commondir` pointer back
   to the main `.git` dir, and walks up one level. Falls back to cwd
   silently for non-worktree setups.

2. MCP server config now also loads from global paths
   (`~/.sf/mcp.json`, `~/.sf/agent/mcp.json`) in addition to the
   existing project-level (`.mcp.json`, `.sf/mcp.json`). First-hit
   wins, so project configs can still shadow or augment a globally-
   registered server by name. This lets the user register unauth'd
   servers like the DeepWiki remote MCP once and have every SF
   project pick it up without per-project `.mcp.json`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-19 08:53:27 +02:00
parent 879dc63a70
commit 9f0723a7be
2 changed files with 48 additions and 3 deletions

View file

@ -24,6 +24,7 @@ import { Client } from "@modelcontextprotocol/sdk/client";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { readFileSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { buildHttpTransportOpts } from "./auth.js";
import type { McpHttpAuthConfig } from "./auth.js";
@ -66,9 +67,15 @@ function readConfigs(): McpServerConfig[] {
const servers: McpServerConfig[] = [];
const seen = new Set<string>();
// Search order matters: first hit wins (seen-guard below), so put
// project-local configs first — a project can override or shadow a
// globally-registered server by re-declaring the same name.
const sfHome = process.env.SF_HOME || join(homedir(), ".sf");
const configPaths = [
join(process.cwd(), ".mcp.json"),
join(process.cwd(), ".sf", "mcp.json"),
join(sfHome, "mcp.json"), // global: ~/.sf/mcp.json
join(sfHome, "agent", "mcp.json"), // global: ~/.sf/agent/mcp.json (legacy alt)
];
for (const configPath of configPaths) {

View file

@ -12,7 +12,7 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { join, dirname, resolve } from "node:path";
import { sfRoot } from "./paths.js";
import { parse as parseYaml } from "yaml";
@ -110,8 +110,46 @@ function legacyGlobalPreferencesPath(): string {
return join(homedir(), ".pi", "agent", "sf-preferences.md");
}
/**
* Resolve the "project root" for preferences. When SF is running inside a
* git worktree (e.g. `.sf/worktrees/M003/`), project-level prefs should
* come from the MAIN worktree's `.sf/PREFERENCES.md`, not the milestone
* branch's frozen copy. Otherwise a pref change on main never reaches an
* in-flight milestone, and we saw this in practice: updating PREFERENCES
* on main had no effect until the milestone branch merged main.
*
* Strategy: read `.git` in the current worktree. If it's a file of the
* form `gitdir: <path>`, we're in a linked worktree resolve the
* commondir and walk up one level to reach the main worktree. Fall back
* to cwd silently for non-worktree setups (bare clones, submodules, etc.
* where the current logic is already correct).
*/
function projectPrefsRoot(): string {
const cwd = process.cwd();
try {
const gitPath = join(cwd, ".git");
if (!existsSync(gitPath)) return cwd;
const stat = readFileSync(gitPath, "utf-8");
// Linked worktrees have `.git` as a FILE: "gitdir: /abs/path/to/main/.git/worktrees/NAME"
const m = /^gitdir:\s*(.+)$/m.exec(stat.trim());
if (!m) return cwd;
const gitdir = m[1].trim();
// gitdir looks like /.../.git/worktrees/NAME — commondir is the /.../.git/ part;
// the main worktree is the directory containing that .git dir.
// Use the `commondir` pointer file inside the linked worktree's gitdir when present.
const commondirFile = join(gitdir, "commondir");
if (existsSync(commondirFile)) {
const rel = readFileSync(commondirFile, "utf-8").trim();
const commondir = resolve(gitdir, rel); // usually "../.." → /.../.git
const mainRoot = dirname(commondir); // /.../ (main worktree root)
if (existsSync(join(mainRoot, ".sf"))) return mainRoot;
}
} catch { /* non-fatal — fall back to cwd */ }
return cwd;
}
function projectPreferencesPath(): string {
return join(sfRoot(process.cwd()), "preferences.md");
return join(sfRoot(projectPrefsRoot()), "preferences.md");
}
// Bootstrap in gitignore.ts historically created PREFERENCES.md (uppercase) by mistake.
// Check uppercase as a fallback so those files aren't silently ignored.
@ -119,7 +157,7 @@ function globalPreferencesPathUppercase(): string {
return join(sfHome(), "PREFERENCES.md");
}
function projectPreferencesPathUppercase(): string {
return join(sfRoot(process.cwd()), "PREFERENCES.md");
return join(sfRoot(projectPrefsRoot()), "PREFERENCES.md");
}
export function getGlobalSFPreferencesPath(): string {