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:
parent
879dc63a70
commit
9f0723a7be
2 changed files with 48 additions and 3 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue