Skip to content

Commit 76a6dd9

Browse files
committed
Add factorly sync — push MCP config to AI client config files
After setting up Factorly, users had to manually create .mcp.json or .cursor/mcp.json to connect their AI client. factorly sync detects installed clients and writes the MCP server entry for them. Sync command (src/cmd/factorly/sync_cmd.go): - Auto-detects AI clients by checking for config directories: Claude Code (.mcp.json, always synced), Cursor (.cursor/mcp.json, if .cursor/ exists), Codex (.codex/mcp.json, if .codex/ exists) - Writes factorly MCP server entry with command + args for stdio transport by default - --http flag: writes streamable-http entry with url instead of command (auto-appends /mcp to the URL) - --command flag: custom factorly binary path - --config flag: adds -c flag to serve args when using non-default config location - --remove flag: removes factorly entry from all detected clients, preserves other MCP server entries - --global flag: syncs to user-level config paths instead of project-level. Claude Code uses ~/.claude.json, Cursor uses ~/.cursor/mcp.json. Codex global is skipped with a clear error (uses TOML config format, not yet supported) JSON merging: - Reads existing config files and preserves all non-factorly entries - Ensures mcpServers key exists, adds/updates factorly entry only - Handles empty files (treats as empty config, not parse error) - Handles missing files (creates new) - Re-marshals with 2-space indentation + trailing newline Platform support: - Uses os.UserHomeDir() for global paths (correct on macOS, Linux, Windows) - Claude Code global config is ~/.claude.json (per Anthropic docs, MCP servers for user scope live here) - Cursor global config is ~/.cursor/mcp.json - Codex global uses TOML (~/.codex/config.toml) — not supported, shows "global sync not supported (uses TOML config)" error Integration tests (5 new): - TestSyncCreatesClaudeCodeConfig: creates .mcp.json with factorly entry - TestSyncPreservesExistingEntries: merges with existing MCP servers - TestSyncRemove: removes factorly while preserving other entries - TestSyncHTTPMode: writes streamable-http type with /mcp URL - TestSyncCursorDetection: creates .cursor/mcp.json when .cursor/ directory exists README: - Quick start updated: factorly sync before factorly serve - Roadmap: factorly sync marked done docs/cli-reference.md: - sync command with --global, --http, --remove flags documented
1 parent 7711467 commit 76a6dd9

5 files changed

Lines changed: 365 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1+
```
2+
░█▀▀░█▀█░█▀▀░▀█▀░█▀█░█▀▄░█░░█░█
3+
░█▀▀░█▀█░█░░░░█░░█░█░█▀▄░█░░░█░
4+
░▀░░░▀░▀░▀▀▀░░▀░░▀▀▀░▀░▀░▀▀▀░▀░
5+
```
6+
7+
<center>
8+
19
# Factorly
210

311
Factorly wraps your existing agent tools — REST APIs, CLIs, MCP servers — into a single endpoint where credentials never reach the agent. Your agent sees tool names and parameters. Factorly injects the auth, makes the call, and returns the data.
412

13+
</center>
14+
515
```bash
616
# Your agent runs this — no secrets anywhere in the command
717
factorly call github.repos --username octocat --per_page 5
@@ -52,7 +62,10 @@ factorly tools add --name web.fetch --type cli --command curl --args '-s,{url}'
5262
# Use it on the command line
5363
factorly call web.fetch --url "https://example.com"
5464

55-
# Start as an MCP server for Claude Code / Cursor
65+
# Connect to Claude Code / Cursor / Codex
66+
factorly sync
67+
68+
# Or start manually as an MCP server
5669
factorly serve
5770
```
5871

@@ -184,7 +197,7 @@ For HTTP mode (remote/shared): `factorly serve --http :3000` — endpoint at `ht
184197
- [x] Interactive CLI tools — `interactive: true` for TTY passthrough
185198
- [x] Call logging (JSONL) + `--verbose` flag
186199
- [x] Security hardening — param redaction, HTTP token auth, vault ref validation
187-
- [ ] `factorly sync` — push MCP config into AI clients (Claude Code, Cursor, Codex)
200+
- [x] `factorly sync` — push MCP config into AI clients (Claude Code, Cursor, Codex)
188201
- [ ] `factorly logs` — view/query the call log
189202
- [ ] External vault backends (1Password, GCP Secret Manager, AWS)
190203
- [ ] Hosted version (Factorly Cloud)

‎docs/cli-reference.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ factorly tools add --name x --type cli # add a tool (non-interactive)
1515
factorly tools remove <tool> # remove a tool from config
1616
factorly tools import openapi <spec> # generate tools from OpenAPI spec
1717
factorly call <tool> [--param val] # call a tool
18+
factorly sync # push MCP config to AI clients
19+
factorly sync --global # sync to user-level config (~/.claude/, etc.)
20+
factorly sync --http localhost:3000 # sync HTTP mode
21+
factorly sync --remove # remove factorly from client configs
1822
factorly status # check all tools are reachable
1923
factorly auth login <provider> # OAuth login (opens browser)
2024
factorly auth status [provider] # show OAuth token status

‎src/cmd/factorly/main.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ func init() {
319319
importCmd.AddCommand(importOpenAPICmd)
320320

321321
toolsCmd.AddCommand(toolsListCmd, addCmd, removeCmd, importCmd)
322-
rootCmd.AddCommand(versionCmd, toolsCmd, callCmd, initCmd, vaultCmd, authCmd, statusCmd, serveCmd)
322+
rootCmd.AddCommand(versionCmd, toolsCmd, callCmd, initCmd, syncCmd, vaultCmd, authCmd, statusCmd, serveCmd)
323323
}
324324

325325
// loadConfig loads config and builds a registry. Does not open the vault

‎src/cmd/factorly/sync_cmd.go‎

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var syncHTTP string
14+
var syncCommand string
15+
var syncRemove bool
16+
var syncGlobal bool
17+
18+
type clientDef struct {
19+
Name string
20+
ConfigPath string
21+
AlwaysSync bool // true = always create/update; false = only if DirCheck exists
22+
DirCheck string // only sync if this directory exists (empty = always)
23+
}
24+
25+
func getClients() []clientDef {
26+
if syncGlobal {
27+
home, err := os.UserHomeDir()
28+
if err != nil {
29+
home = "~"
30+
}
31+
// Claude Code user-level MCP config is inside ~/.claude.json
32+
// Cursor uses ~/.cursor/mcp.json
33+
// Codex global config uses TOML — not supported yet
34+
return []clientDef{
35+
{Name: "Claude Code", ConfigPath: filepath.Join(home, ".claude.json"), AlwaysSync: true},
36+
{Name: "Cursor", ConfigPath: filepath.Join(home, ".cursor", "mcp.json"), DirCheck: filepath.Join(home, ".cursor")},
37+
{Name: "Codex", ConfigPath: "", DirCheck: filepath.Join(home, ".codex")},
38+
}
39+
}
40+
return []clientDef{
41+
{Name: "Claude Code", ConfigPath: ".mcp.json", AlwaysSync: true},
42+
{Name: "Cursor", ConfigPath: ".cursor/mcp.json", DirCheck: ".cursor"},
43+
{Name: "Codex", ConfigPath: ".codex/mcp.json", DirCheck: ".codex"},
44+
}
45+
}
46+
47+
var syncCmd = &cobra.Command{
48+
Use: "sync",
49+
Short: "Push Factorly MCP config into AI client config files",
50+
Long: `Detect installed AI clients and write the Factorly MCP server entry
51+
into their config files (.mcp.json, .cursor/mcp.json, etc.).
52+
53+
Existing MCP server entries in the config files are preserved.`,
54+
RunE: runSync,
55+
}
56+
57+
func runSync(cmd *cobra.Command, args []string) error {
58+
entry := buildFactorlyEntry()
59+
synced := 0
60+
activeClients := getClients()
61+
62+
fmt.Fprintln(os.Stderr)
63+
if syncGlobal {
64+
fmt.Fprintln(os.Stderr, " Detected clients (global):")
65+
} else {
66+
fmt.Fprintln(os.Stderr, " Detected clients:")
67+
}
68+
69+
for _, c := range activeClients {
70+
if !c.AlwaysSync && c.DirCheck != "" {
71+
if info, err := os.Stat(c.DirCheck); err != nil || !info.IsDir() {
72+
fmt.Fprintf(os.Stderr, " - %-14s %s not found, skipping\n", c.Name, c.DirCheck+"/")
73+
continue
74+
}
75+
}
76+
77+
if c.ConfigPath == "" {
78+
fmt.Fprintf(os.Stderr, " ✗ %-14s global sync not supported (uses TOML config)\n", c.Name)
79+
continue
80+
}
81+
82+
if syncRemove {
83+
removed, err := removeFactorlyFromJSON(c.ConfigPath)
84+
if err != nil {
85+
fmt.Fprintf(os.Stderr, " ✗ %-14s %v\n", c.Name, err)
86+
continue
87+
}
88+
if removed {
89+
fmt.Fprintf(os.Stderr, " ✓ %-14s removed from %s\n", c.Name, c.ConfigPath)
90+
synced++
91+
} else {
92+
fmt.Fprintf(os.Stderr, " - %-14s not configured in %s\n", c.Name, c.ConfigPath)
93+
}
94+
} else {
95+
if err := mergeFactorlyIntoJSON(c.ConfigPath, entry); err != nil {
96+
fmt.Fprintf(os.Stderr, " ✗ %-14s %v\n", c.Name, err)
97+
continue
98+
}
99+
fmt.Fprintf(os.Stderr, " ✓ %-14s wrote %s\n", c.Name, c.ConfigPath)
100+
synced++
101+
}
102+
}
103+
104+
fmt.Fprintln(os.Stderr)
105+
if syncRemove {
106+
fmt.Fprintf(os.Stderr, " Removed from %d clients.\n", synced)
107+
} else {
108+
fmt.Fprintf(os.Stderr, " Synced %d clients.\n", synced)
109+
}
110+
return nil
111+
}
112+
113+
func buildFactorlyEntry() map[string]any {
114+
if syncHTTP != "" {
115+
// HTTP mode
116+
url := syncHTTP
117+
if url[0] != 'h' {
118+
url = "http://" + url
119+
}
120+
if filepath.Ext(url) == "" && url[len(url)-1] != '/' {
121+
url += "/mcp"
122+
}
123+
return map[string]any{
124+
"type": "streamable-http",
125+
"url": url,
126+
}
127+
}
128+
129+
// Stdio mode
130+
cmd := syncCommand
131+
if cmd == "" {
132+
cmd = "factorly"
133+
}
134+
135+
serveArgs := []string{"serve"}
136+
137+
// Add config flag if using non-default config location
138+
if configPath != "" {
139+
serveArgs = append(serveArgs, "-c", configPath)
140+
}
141+
142+
return map[string]any{
143+
"command": cmd,
144+
"args": serveArgs,
145+
}
146+
}
147+
148+
func mergeFactorlyIntoJSON(path string, entry map[string]any) error {
149+
var root map[string]any
150+
151+
data, err := os.ReadFile(path)
152+
if err != nil {
153+
if !os.IsNotExist(err) {
154+
return fmt.Errorf("reading %s: %w", path, err)
155+
}
156+
root = make(map[string]any)
157+
} else if len(strings.TrimSpace(string(data))) == 0 {
158+
root = make(map[string]any)
159+
} else {
160+
if err := json.Unmarshal(data, &root); err != nil {
161+
return fmt.Errorf("parsing %s: %w", path, err)
162+
}
163+
}
164+
165+
// Ensure mcpServers exists
166+
servers, ok := root["mcpServers"].(map[string]any)
167+
if !ok {
168+
servers = make(map[string]any)
169+
}
170+
171+
servers["factorly"] = entry
172+
root["mcpServers"] = servers
173+
174+
// Ensure parent directory exists
175+
if dir := filepath.Dir(path); dir != "." {
176+
if err := os.MkdirAll(dir, 0o755); err != nil {
177+
return fmt.Errorf("creating directory: %w", err)
178+
}
179+
}
180+
181+
out, err := json.MarshalIndent(root, "", " ")
182+
if err != nil {
183+
return fmt.Errorf("marshaling: %w", err)
184+
}
185+
out = append(out, '\n')
186+
187+
return os.WriteFile(path, out, 0o644)
188+
}
189+
190+
func removeFactorlyFromJSON(path string) (bool, error) {
191+
data, err := os.ReadFile(path)
192+
if err != nil {
193+
if os.IsNotExist(err) {
194+
return false, nil
195+
}
196+
return false, fmt.Errorf("reading %s: %w", path, err)
197+
}
198+
199+
var root map[string]any
200+
if err := json.Unmarshal(data, &root); err != nil {
201+
return false, fmt.Errorf("parsing %s: %w", path, err)
202+
}
203+
204+
servers, ok := root["mcpServers"].(map[string]any)
205+
if !ok {
206+
return false, nil
207+
}
208+
209+
if _, exists := servers["factorly"]; !exists {
210+
return false, nil
211+
}
212+
213+
delete(servers, "factorly")
214+
root["mcpServers"] = servers
215+
216+
out, err := json.MarshalIndent(root, "", " ")
217+
if err != nil {
218+
return false, fmt.Errorf("marshaling: %w", err)
219+
}
220+
out = append(out, '\n')
221+
222+
if err := os.WriteFile(path, out, 0o644); err != nil {
223+
return false, fmt.Errorf("writing %s: %w", path, err)
224+
}
225+
return true, nil
226+
}
227+
228+
func init() {
229+
syncCmd.Flags().StringVar(&syncHTTP, "http", "", "sync HTTP mode with this address (e.g. localhost:3000)")
230+
syncCmd.Flags().StringVar(&syncCommand, "command", "", "custom factorly binary path")
231+
syncCmd.Flags().BoolVar(&syncRemove, "remove", false, "remove factorly entry from client configs")
232+
syncCmd.Flags().BoolVar(&syncGlobal, "global", false, "sync to user-level config (~/.claude/, ~/.cursor/, ~/.codex/)")
233+
}

0 commit comments

Comments
 (0)