@imenam/mcp-documentor 1.0.56 → 1.0.57

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
@@ -14,6 +14,7 @@ Connect it to Cursor, Claude Desktop, or any MCP-compatible agent to let your AI
14
14
  - **Proxy-aware** — integrates with the [proxy-setup](https://github.com/Imena/proxy-setup) for GUI exposure
15
15
  - **Links & structure** — `check_links`, `get_doc_tree`, `move_doc`, and `git_diff_doc` for maintenance workflows
16
16
  - **Image support** — upload images to a project's assets via `upload_image` (local file path) or directly from the GUI editor; images are stored per-project and rendered inline in the viewer and in PDF exports
17
+ - **GitHub sync** — share your documentation across machines by backing the data directory with a private GitHub repo; the repo/token are fixed by the administrator via environment variables, and the agent can only `pull_docs` / `push_docs` / `sync_docs`
17
18
 
18
19
  ## Installation
19
20
 
@@ -59,6 +60,10 @@ Add to your MCP client config (e.g. Cursor `mcp.json`):
59
60
  | `PROXY_URL` | No | URL of the proxy server to register and launch the GUI |
60
61
  | `MCP_DATA_DIR` | No | Overrides the default data directory (see Data storage) |
61
62
  | `READ_ONLY` | No | When set to `1`/`true`/`yes`, disables all create/update/delete tools and GUI write actions (read-only mode) |
63
+ | `MCP_DOCUMENTOR_SYNC_REMOTE` | No | HTTPS URL of the private GitHub repo to sync with; enables GitHub sync when set |
64
+ | `MCP_DOCUMENTOR_SYNC_BRANCH` | No | Branch to sync (default: `main`) |
65
+ | `MCP_DOCUMENTOR_SYNC_TOKEN` | No | Fine-grained GitHub PAT used for HTTPS auth (see GitHub sync) |
66
+ | `SYNC_PULL_ON_START` | No | Set to `false` to disable the automatic pull at server startup |
62
67
 
63
68
  Optional `.env` at the package root (directory containing `package.json` discovered from the installed binary) is loaded when present.
64
69
 
@@ -83,6 +88,11 @@ Optional `.env` at the package root (directory containing `package.json` discove
83
88
  | `list_projects` | List all documentation projects |
84
89
  | `set_project` | Switch the active project **for the GUI only** (not for MCP tools) |
85
90
  | `upload_image` | Upload an image from a local file path to a project's assets; returns the markdown link to insert |
91
+ | `sync_status` | Show branch, ahead/behind counts, conflicted files, and uncommitted changes |
92
+ | `pull_docs` | Pull updates from the remote (defensive fast-forward; reindexes new docs) |
93
+ | `push_docs` | Commit local doc changes and push them to the remote |
94
+ | `sync_docs` | Full sync: pull then push (push skipped if the pull reveals a conflict) |
95
+ | `resolve_sync_conflict` | Bulk-resolve merge conflicts by keeping `mine` or `theirs`, then stage |
86
96
 
87
97
  ## Images in documents
88
98
 
@@ -110,10 +120,69 @@ The tool copies the file into `.docs-data/projects/{project}/assets/` and return
110
120
  - **URL tab** — paste any external image URL; the markdown link is inserted at the cursor position
111
121
  - **Project images gallery** — click any previously uploaded image to re-insert it
112
122
 
123
+ ## Syncing across machines with GitHub
124
+
125
+ You can share documentation between several machines by backing the data directory with a **private** GitHub repository. The approach is git-based: only the human-readable markdown, assets, and the shared project list are versioned. Derived state (embeddings) is regenerated locally, and version history stays local to each machine.
126
+
127
+ ### What is and isn't synced
128
+
129
+ | Synced (versioned) | Local only (gitignored) |
130
+ |---|---|
131
+ | `projects/**` (markdown + assets) | `embeddings.db` (regenerated after pull) |
132
+ | `config.json` (project list) | `history.db` (per-machine version history) |
133
+ | | `local-state.json` (active project) |
134
+
135
+ ### Setup
136
+
137
+ The sync target is **administrator-controlled**: the repository, branch and token are read from environment variables and **cannot be set or changed by the agent**. The agent can only pull/push against whatever repo you configured.
138
+
139
+ 1. Create an **empty private repo** on GitHub (e.g. `my-docs`).
140
+ 2. Create a **fine-grained PAT** scoped to that single repo (Repository permissions → **Contents: Read and write**).
141
+ 3. Add the sync environment variables to your MCP client config, per machine:
142
+
143
+ ```jsonc
144
+ {
145
+ "mcpServers": {
146
+ "mcp-documentor": {
147
+ "command": "npx",
148
+ "args": ["-y", "@imenam/mcp-documentor"],
149
+ "env": {
150
+ "MCP_DOCUMENTOR_SYNC_REMOTE": "https://github.com/<you>/my-docs.git",
151
+ "MCP_DOCUMENTOR_SYNC_BRANCH": "main", // optional, defaults to "main"
152
+ "MCP_DOCUMENTOR_SYNC_TOKEN": "github_pat_..."
153
+ }
154
+ }
155
+ }
156
+ }
157
+ ```
158
+
159
+ The token is injected into the `origin` URL inside `.git/config` only (which git never commits) — it is never pushed.
160
+
161
+ ### Daily workflow
162
+
163
+ - `sync_docs` — pull then push in one step (recommended).
164
+ - `pull_docs` / `push_docs` — granular control.
165
+ - `sync_status` — inspect ahead/behind, conflicts, and uncommitted changes.
166
+
167
+ By default the server also performs a **defensive pull at startup**: it only fast-forwards a clean working tree and never overwrites local changes (disable with `SYNC_PULL_ON_START=false`). On the first run, a machine with no local data simply clones the remote.
168
+
169
+ ### Conflicts
170
+
171
+ Because docs are plain markdown, merge conflicts are readable. When `pull_docs` reports a conflict:
172
+
173
+ - edit the conflicted `.md` files directly (remove the `<<<<<<<` / `>>>>>>>` markers), **or**
174
+ - run `resolve_sync_conflict` with `strategy: "mine"` or `"theirs"` to take one side in bulk,
175
+
176
+ then run `push_docs` to publish the resolution. If you have **uncommitted** local changes while the remote has moved, the pull reports `diverged` without touching anything — push your changes first, then pull again.
177
+
178
+ > Read-only machines (`READ_ONLY=1`) can still `pull_docs` and `sync_status`, but `push_docs`, `sync_docs`, and `resolve_sync_conflict` are disabled.
179
+
113
180
  ## Data Storage
114
181
 
115
182
  By default, data lives under **`.docs-data`** next to the resolved package root (the folder that contains `package.json` for the running install). Set **`MCP_DATA_DIR`** to use another directory. Contents include projects, SQLite/embeddings, and logs under `.docs-data/logs`. This tree is **not** part of the npm package; it is created at runtime.
116
183
 
184
+ The active project is a machine-local preference kept in `local-state.json` (gitignored), separate from the shared `config.json`. On first run, a legacy `currentProject` stored in `config.json` is migrated automatically. GitHub sync settings live in environment variables, not on disk.
185
+
117
186
  ## License
118
187
 
119
188
  ISC
@@ -8,4 +8,7 @@ export const WRITE_TOOLS = new Set([
8
8
  "create_doc", "update_doc", "patch_doc", "move_doc", "delete_doc",
9
9
  "restore_doc_version", "refresh_file_snapshot", "reindex_embeddings",
10
10
  "prepare_export_bundle", "delete_export_bundle", "upload_image",
11
+ // Sync: pull_docs and sync_status stay allowed (read-only consumer machines
12
+ // still pull updates); these mutate local/remote state and are blocked.
13
+ "push_docs", "sync_docs", "resolve_sync_conflict",
11
14
  ]);
@@ -0,0 +1,56 @@
1
+ import type { StorageEngine } from "./storage-engine.js";
2
+ export interface SyncStatus {
3
+ branch: string;
4
+ ahead: number;
5
+ behind: number;
6
+ dirty: string[];
7
+ conflicted: string[];
8
+ }
9
+ export type PullResult = {
10
+ status: "up-to-date";
11
+ } | {
12
+ status: "updated";
13
+ changed: string[];
14
+ } | {
15
+ status: "diverged";
16
+ ahead: number;
17
+ behind: number;
18
+ } | {
19
+ status: "conflict";
20
+ conflicted: string[];
21
+ };
22
+ export declare class SyncEngine {
23
+ private storage;
24
+ private git;
25
+ private dataDir;
26
+ constructor(storage: StorageEngine);
27
+ isConfigured(): boolean;
28
+ /**
29
+ * Builds an authenticated HTTPS URL by injecting the PAT. The result is only
30
+ * ever stored in .git/config (which git never commits), never versioned.
31
+ */
32
+ private authedUrl;
33
+ private requireConfig;
34
+ /**
35
+ * Ensures the data dir is a git repo with a .gitignore and an authenticated
36
+ * origin. The token is stored only in .git/config (which git never commits),
37
+ * so normal origin-based fetch/push work and upstream tracking — required for
38
+ * ahead/behind detection — stays accurate.
39
+ */
40
+ ensureRepo(): Promise<void>;
41
+ status(): Promise<SyncStatus>;
42
+ /**
43
+ * Defensive pull: only fast-forwards a clean working tree. Never auto-merges
44
+ * a divergent history, so local data is never silently overwritten.
45
+ */
46
+ pull(): Promise<PullResult>;
47
+ private diffNames;
48
+ /** Stages versioned files, commits if there is anything to commit, and pushes. */
49
+ push(message?: string): Promise<{
50
+ pushed: boolean;
51
+ committed: boolean;
52
+ message: string;
53
+ }>;
54
+ /** Bulk-resolve conflicts by taking one side, then stage the resolved files. */
55
+ resolveConflict(strategy: "mine" | "theirs", paths?: string[]): Promise<string[]>;
56
+ }
@@ -0,0 +1,215 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { simpleGit } from "simple-git";
4
+ import { createLogger } from "@imenam/mcp-gui-interface";
5
+ const logger = createLogger("GitSync");
6
+ const GITIGNORE_CONTENT = [
7
+ "# Derived / machine-local state — never synced",
8
+ "embeddings.db",
9
+ "embeddings.db-shm",
10
+ "embeddings.db-wal",
11
+ "history.db",
12
+ "history.db-shm",
13
+ "history.db-wal",
14
+ "*.db-shm",
15
+ "*.db-wal",
16
+ "logs/",
17
+ "temps/",
18
+ "errors-tmp-files/",
19
+ "export-bundles.json",
20
+ "local-state.json",
21
+ "",
22
+ ].join("\n");
23
+ export class SyncEngine {
24
+ storage;
25
+ git;
26
+ dataDir;
27
+ constructor(storage) {
28
+ this.storage = storage;
29
+ this.dataDir = storage.getDataDir();
30
+ fs.mkdirSync(this.dataDir, { recursive: true });
31
+ this.git = simpleGit(this.dataDir);
32
+ }
33
+ isConfigured() {
34
+ const cfg = this.storage.getSyncConfig();
35
+ return !!cfg?.remoteUrl;
36
+ }
37
+ /**
38
+ * Builds an authenticated HTTPS URL by injecting the PAT. The result is only
39
+ * ever stored in .git/config (which git never commits), never versioned.
40
+ */
41
+ authedUrl(cfg) {
42
+ const token = cfg.token;
43
+ if (!token)
44
+ return cfg.remoteUrl;
45
+ try {
46
+ const u = new URL(cfg.remoteUrl);
47
+ if (u.protocol !== "https:")
48
+ return cfg.remoteUrl;
49
+ u.username = token;
50
+ u.password = "";
51
+ return u.toString();
52
+ }
53
+ catch {
54
+ return cfg.remoteUrl;
55
+ }
56
+ }
57
+ requireConfig() {
58
+ const cfg = this.storage.getSyncConfig();
59
+ if (!cfg?.remoteUrl) {
60
+ throw new Error("Sync is not configured. Set MCP_DOCUMENTOR_SYNC_REMOTE (and MCP_DOCUMENTOR_SYNC_TOKEN) in the server environment.");
61
+ }
62
+ return cfg;
63
+ }
64
+ /**
65
+ * Ensures the data dir is a git repo with a .gitignore and an authenticated
66
+ * origin. The token is stored only in .git/config (which git never commits),
67
+ * so normal origin-based fetch/push work and upstream tracking — required for
68
+ * ahead/behind detection — stays accurate.
69
+ */
70
+ async ensureRepo() {
71
+ const cfg = this.requireConfig();
72
+ const authed = this.authedUrl(cfg);
73
+ const gitignorePath = path.join(this.dataDir, ".gitignore");
74
+ if (!fs.existsSync(gitignorePath)) {
75
+ fs.writeFileSync(gitignorePath, GITIGNORE_CONTENT, "utf-8");
76
+ }
77
+ if (!fs.existsSync(path.join(this.dataDir, ".git"))) {
78
+ await this.git.init();
79
+ logger.info(`Initialized git repo in ${this.dataDir}`);
80
+ }
81
+ // (Re)point origin at the authenticated URL.
82
+ const remotes = await this.git.getRemotes(true);
83
+ const origin = remotes.find((r) => r.name === "origin");
84
+ if (!origin) {
85
+ await this.git.addRemote("origin", authed);
86
+ }
87
+ else if (origin.refs.fetch !== authed) {
88
+ await this.git.remote(["set-url", "origin", authed]);
89
+ }
90
+ // Make sure we're on the configured branch.
91
+ const branches = await this.git.branchLocal();
92
+ if (branches.current !== cfg.branch) {
93
+ try {
94
+ await this.git.checkoutLocalBranch(cfg.branch);
95
+ }
96
+ catch {
97
+ await this.git.checkout(cfg.branch);
98
+ }
99
+ }
100
+ }
101
+ async status() {
102
+ this.requireConfig();
103
+ const s = await this.git.status();
104
+ return {
105
+ branch: s.current ?? "",
106
+ ahead: s.ahead,
107
+ behind: s.behind,
108
+ dirty: s.files.map((f) => f.path),
109
+ conflicted: s.conflicted,
110
+ };
111
+ }
112
+ /**
113
+ * Defensive pull: only fast-forwards a clean working tree. Never auto-merges
114
+ * a divergent history, so local data is never silently overwritten.
115
+ */
116
+ async pull() {
117
+ const cfg = this.requireConfig();
118
+ await this.ensureRepo();
119
+ await this.git.fetch("origin", cfg.branch);
120
+ const remoteRef = `refs/remotes/origin/${cfg.branch}`;
121
+ // No remote branch yet (fresh repo) — nothing to pull.
122
+ const hasRemote = await this.git
123
+ .raw(["rev-parse", "--verify", "--quiet", remoteRef])
124
+ .then(() => true)
125
+ .catch(() => false);
126
+ if (!hasRemote)
127
+ return { status: "up-to-date" };
128
+ // Fresh clone: the local branch has no commit yet, so status can't compute
129
+ // ahead/behind. Materialize the remote branch directly.
130
+ const hasLocalCommit = await this.git.revparse(["--verify", "HEAD"]).then(() => true).catch(() => false);
131
+ if (!hasLocalCommit) {
132
+ await this.git.raw(["reset", "--hard", `origin/${cfg.branch}`]);
133
+ await this.git.branch(["--set-upstream-to", `origin/${cfg.branch}`, cfg.branch]).catch(() => { });
134
+ const changed = (await this.git.raw(["ls-tree", "-r", "--name-only", "HEAD"])).split("\n").filter(Boolean);
135
+ return { status: "updated", changed };
136
+ }
137
+ // Ensure upstream tracking so status ahead/behind is meaningful.
138
+ await this.git.branch(["--set-upstream-to", `origin/${cfg.branch}`, cfg.branch]).catch(() => { });
139
+ const status = await this.git.status();
140
+ if (status.behind === 0)
141
+ return { status: "up-to-date" };
142
+ // Uncommitted local changes block a safe merge — never risk clobbering them.
143
+ // Surface as diverged so the user commits/pushes (or stashes) first.
144
+ if (status.files.length > 0) {
145
+ return { status: "diverged", ahead: status.ahead, behind: status.behind };
146
+ }
147
+ const before = await this.git.revparse(["HEAD"]).catch(() => "");
148
+ // Pure behind → fast-forward.
149
+ if (status.ahead === 0) {
150
+ await this.git.merge(["--ff-only", `origin/${cfg.branch}`]);
151
+ const after = await this.git.revparse(["HEAD"]).catch(() => "");
152
+ return { status: "updated", changed: await this.diffNames(before, after) };
153
+ }
154
+ // Diverged with a clean tree → attempt a real merge. A conflict stops with
155
+ // markers in place (both versions preserved) so it can be resolved + pushed.
156
+ try {
157
+ await this.git.merge([`origin/${cfg.branch}`]);
158
+ }
159
+ catch {
160
+ const after = await this.git.status();
161
+ if (after.conflicted.length > 0)
162
+ return { status: "conflict", conflicted: after.conflicted };
163
+ throw new Error("Merge failed without recorded conflicts. Resolve manually in the data directory.");
164
+ }
165
+ const merged = await this.git.revparse(["HEAD"]).catch(() => "");
166
+ return { status: "updated", changed: await this.diffNames(before, merged) };
167
+ }
168
+ async diffNames(before, after) {
169
+ if (!before || !after || before === after)
170
+ return [];
171
+ return (await this.git.raw(["diff", "--name-only", before, after])).split("\n").filter(Boolean);
172
+ }
173
+ /** Stages versioned files, commits if there is anything to commit, and pushes. */
174
+ async push(message) {
175
+ const cfg = this.requireConfig();
176
+ await this.ensureRepo();
177
+ // .gitignore (written by ensureRepo) excludes all derived/local state,
178
+ // so -A safely stages only versioned files (projects/, config.json, .gitignore).
179
+ await this.git.add(["-A"]);
180
+ const status = await this.git.status();
181
+ let committed = false;
182
+ if (status.staged.length > 0 || status.created.length > 0 || status.deleted.length > 0 || status.modified.length > 0) {
183
+ const machine = process.env.COMPUTERNAME || process.env.HOSTNAME || "unknown-machine";
184
+ const msg = message?.trim() || `docs sync from ${machine} · ${new Date().toISOString()}`;
185
+ await this.git.commit(msg);
186
+ committed = true;
187
+ }
188
+ try {
189
+ await this.git.push(["-u", "origin", `HEAD:${cfg.branch}`]);
190
+ }
191
+ catch (e) {
192
+ const msg = e instanceof Error ? e.message : String(e);
193
+ if (/fetch first|non-fast-forward|\[rejected\]/i.test(msg)) {
194
+ throw new Error("Push rejected: the remote has commits you don't have locally. Run pull_docs to integrate them, then push again.");
195
+ }
196
+ throw e;
197
+ }
198
+ return { pushed: true, committed, message: committed ? "Committed and pushed." : "Nothing new to commit; pushed existing commits." };
199
+ }
200
+ /** Bulk-resolve conflicts by taking one side, then stage the resolved files. */
201
+ async resolveConflict(strategy, paths) {
202
+ this.requireConfig();
203
+ const status = await this.git.status();
204
+ const targets = paths && paths.length > 0 ? paths : status.conflicted;
205
+ if (targets.length === 0)
206
+ return [];
207
+ // During a merge, "ours" = current branch (mine), "theirs" = incoming.
208
+ const sideFlag = strategy === "mine" ? "--ours" : "--theirs";
209
+ for (const p of targets) {
210
+ await this.git.raw(["checkout", sideFlag, "--", p]);
211
+ await this.git.add([p]);
212
+ }
213
+ return targets;
214
+ }
215
+ }
@@ -5,6 +5,17 @@ interface GlobalConfig {
5
5
  projects: string[];
6
6
  foldersMigrated?: boolean;
7
7
  }
8
+ /**
9
+ * GitHub sync settings. Sourced exclusively from environment variables — the
10
+ * repository, branch and token are administration concerns and must never be
11
+ * settable by the agent at runtime.
12
+ */
13
+ export interface SyncConfig {
14
+ remoteUrl: string;
15
+ branch: string;
16
+ /** PAT for HTTPS auth (MCP_DOCUMENTOR_SYNC_TOKEN). */
17
+ token?: string;
18
+ }
8
19
  export declare class StorageEngine {
9
20
  private rootDir;
10
21
  private historyStore?;
@@ -13,8 +24,19 @@ export declare class StorageEngine {
13
24
  getDataDir(): string;
14
25
  getProjectDir(project?: string): string;
15
26
  private getConfigPath;
27
+ private getLocalStatePath;
28
+ private readLocalState;
29
+ private writeLocalState;
30
+ /**
31
+ * Returns the merged config. The shared part (projects list, migration flags)
32
+ * comes from config.json (versioned), while currentProject is a machine-local
33
+ * preference sourced from local-state.json (gitignored). Legacy config.json
34
+ * files that still embed currentProject are migrated transparently.
35
+ */
16
36
  getConfig(): GlobalConfig;
17
37
  saveConfig(config: GlobalConfig): void;
38
+ /** Reads sync settings from environment variables (admin-controlled, not agent-settable). */
39
+ getSyncConfig(): SyncConfig | undefined;
18
40
  ensureProject(project: string): void;
19
41
  private walkMdFiles;
20
42
  private docIdToFilePath;
@@ -27,22 +27,81 @@ export class StorageEngine {
27
27
  getConfigPath() {
28
28
  return path.join(this.getDataDir(), "config.json");
29
29
  }
30
+ getLocalStatePath() {
31
+ return path.join(this.getDataDir(), "local-state.json");
32
+ }
33
+ readLocalState() {
34
+ const p = this.getLocalStatePath();
35
+ if (fs.existsSync(p)) {
36
+ try {
37
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
38
+ }
39
+ catch {
40
+ logger.warn("Failed to parse local-state.json, using defaults");
41
+ }
42
+ }
43
+ return {};
44
+ }
45
+ writeLocalState(state) {
46
+ const p = this.getLocalStatePath();
47
+ fs.mkdirSync(path.dirname(p), { recursive: true });
48
+ fs.writeFileSync(p, JSON.stringify(state, null, 2), "utf-8");
49
+ }
50
+ /**
51
+ * Returns the merged config. The shared part (projects list, migration flags)
52
+ * comes from config.json (versioned), while currentProject is a machine-local
53
+ * preference sourced from local-state.json (gitignored). Legacy config.json
54
+ * files that still embed currentProject are migrated transparently.
55
+ */
30
56
  getConfig() {
31
57
  const configPath = this.getConfigPath();
58
+ let shared = {};
32
59
  if (fs.existsSync(configPath)) {
33
60
  try {
34
- return JSON.parse(fs.readFileSync(configPath, "utf-8"));
61
+ shared = JSON.parse(fs.readFileSync(configPath, "utf-8"));
35
62
  }
36
63
  catch {
37
64
  logger.warn("Failed to parse config.json, using defaults");
38
65
  }
39
66
  }
40
- return { currentProject: "default", projects: ["default"] };
67
+ const local = this.readLocalState();
68
+ const projects = shared.projects ?? ["default"];
69
+ // Migrate a legacy currentProject embedded in config.json into local-state.
70
+ if (shared.currentProject !== undefined && local.currentProject === undefined) {
71
+ local.currentProject = shared.currentProject;
72
+ this.writeLocalState(local);
73
+ delete shared.currentProject;
74
+ fs.writeFileSync(configPath, JSON.stringify(shared, null, 2), "utf-8");
75
+ logger.info("Migrated currentProject from config.json to local-state.json");
76
+ }
77
+ return {
78
+ currentProject: local.currentProject ?? projects[0] ?? "default",
79
+ projects,
80
+ foldersMigrated: shared.foldersMigrated,
81
+ };
41
82
  }
42
83
  saveConfig(config) {
84
+ // Shared, versioned part → config.json (currentProject is intentionally excluded).
43
85
  const configPath = this.getConfigPath();
86
+ const shared = {
87
+ projects: config.projects,
88
+ foldersMigrated: config.foldersMigrated,
89
+ };
44
90
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
45
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
91
+ fs.writeFileSync(configPath, JSON.stringify(shared, null, 2), "utf-8");
92
+ // Machine-local part → local-state.json.
93
+ const local = this.readLocalState();
94
+ local.currentProject = config.currentProject;
95
+ this.writeLocalState(local);
96
+ }
97
+ /** Reads sync settings from environment variables (admin-controlled, not agent-settable). */
98
+ getSyncConfig() {
99
+ const remoteUrl = process.env.MCP_DOCUMENTOR_SYNC_REMOTE?.trim();
100
+ if (!remoteUrl)
101
+ return undefined;
102
+ const branch = process.env.MCP_DOCUMENTOR_SYNC_BRANCH?.trim() || "main";
103
+ const token = process.env.MCP_DOCUMENTOR_SYNC_TOKEN?.trim() || undefined;
104
+ return { remoteUrl, branch, token };
46
105
  }
47
106
  ensureProject(project) {
48
107
  const projectDir = path.join(this.getDataDir(), "projects", project);
package/dist/src/index.js CHANGED
@@ -39,6 +39,8 @@ import { prepareExportBundle } from "./tools/prepare-export-bundle.js";
39
39
  import { deleteExportBundle } from "./tools/delete-export-bundle.js";
40
40
  import { uploadImage } from "./tools/upload-image.js";
41
41
  import { getImage } from "./tools/get-image.js";
42
+ import { SyncEngine } from "./core/git-sync.js";
43
+ import { syncStatus, pullDocs, pushDocs, syncDocs, resolveSyncConflict } from "./tools/sync.js";
42
44
  import { isReadOnly, WRITE_TOOLS } from "../shared/read-only.js";
43
45
  const __filename = fileURLToPath(import.meta.url);
44
46
  const __dirname = path.dirname(__filename);
@@ -75,6 +77,7 @@ embeddingStore.init(storageEngine.getDataDir(), openaiModel);
75
77
  const historyStore = new HistoryStore();
76
78
  historyStore.init(storageEngine.getDataDir());
77
79
  storageEngine.setHistoryStore(historyStore);
80
+ const syncEngine = new SyncEngine(storageEngine);
78
81
  const server = new Server({ name: "mcp-documentor", version: APP_VERSION }, { capabilities: { tools: {} } });
79
82
  let guiLauncher;
80
83
  const sseControllers = new Set();
@@ -1030,6 +1033,48 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1030
1033
  required: ["uri"],
1031
1034
  },
1032
1035
  },
1036
+ {
1037
+ name: "sync_status",
1038
+ description: "Show the synchronization status: current branch, how many commits the local repo is ahead/behind the remote, conflicted files, and uncommitted changes. The sync target (repository, branch, token) is fixed by the server administrator via environment variables and cannot be changed from here.",
1039
+ inputSchema: { type: "object", properties: {} },
1040
+ },
1041
+ {
1042
+ name: "pull_docs",
1043
+ description: "Pull documentation updates from the GitHub remote. Defensive: only fast-forwards a clean working tree, never auto-merges a divergent history (local data is never overwritten). Reindexes embeddings for new documents after a successful update.",
1044
+ inputSchema: { type: "object", properties: {} },
1045
+ },
1046
+ {
1047
+ name: "push_docs",
1048
+ description: "Commit local documentation changes (markdown + assets + project list) and push them to the GitHub remote.",
1049
+ inputSchema: {
1050
+ type: "object",
1051
+ properties: {
1052
+ message: { type: "string", description: "Optional commit message (a default with machine name and timestamp is used otherwise)" },
1053
+ },
1054
+ },
1055
+ },
1056
+ {
1057
+ name: "sync_docs",
1058
+ description: "Full synchronization: pull (defensive) then push. If the pull reveals a divergence or conflict, the push is skipped so it can be resolved first.",
1059
+ inputSchema: {
1060
+ type: "object",
1061
+ properties: {
1062
+ message: { type: "string", description: "Optional commit message for the push step" },
1063
+ },
1064
+ },
1065
+ },
1066
+ {
1067
+ name: "resolve_sync_conflict",
1068
+ description: "Bulk-resolve sync merge conflicts by keeping one side for the conflicted files, then stage them. For nuanced resolution, edit the conflicted markdown files directly instead. After resolving, run push_docs.",
1069
+ inputSchema: {
1070
+ type: "object",
1071
+ properties: {
1072
+ strategy: { type: "string", enum: ["mine", "theirs"], description: "'mine' keeps the local version, 'theirs' keeps the incoming remote version" },
1073
+ paths: { type: "array", items: { type: "string" }, description: "Specific conflicted file paths to resolve (defaults to all conflicted files)" },
1074
+ },
1075
+ required: ["strategy"],
1076
+ },
1077
+ },
1033
1078
  ];
1034
1079
  return {
1035
1080
  tools: isReadOnly() ? tools.filter((t) => !WRITE_TOOLS.has(t.name)) : tools,
@@ -1111,6 +1156,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1111
1156
  return await uploadImage(storageEngine, args);
1112
1157
  case "get_image":
1113
1158
  return await getImage(storageEngine, args);
1159
+ case "sync_status":
1160
+ return await syncStatus(syncEngine);
1161
+ case "pull_docs":
1162
+ return await pullDocs(syncEngine, storageEngine, embeddingEngine, embeddingStore);
1163
+ case "push_docs":
1164
+ return await pushDocs(syncEngine, args);
1165
+ case "sync_docs":
1166
+ return await syncDocs(syncEngine, storageEngine, embeddingEngine, embeddingStore, args);
1167
+ case "resolve_sync_conflict":
1168
+ return await resolveSyncConflict(syncEngine, args);
1114
1169
  default:
1115
1170
  throw new Error(`Tool not found: ${name}`);
1116
1171
  }
@@ -1122,9 +1177,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1122
1177
  };
1123
1178
  }
1124
1179
  });
1180
+ async function syncPullOnStartup() {
1181
+ if (!syncEngine.isConfigured())
1182
+ return;
1183
+ if ((process.env.SYNC_PULL_ON_START ?? "").trim().toLowerCase() === "false")
1184
+ return;
1185
+ try {
1186
+ const res = await syncEngine.pull();
1187
+ if (res.status === "updated") {
1188
+ logger.info(`Startup sync: pulled ${res.changed.length} changed file(s).`);
1189
+ if (embeddingEngine.isConfigured()) {
1190
+ await reindexEmbeddings(storageEngine, embeddingEngine, embeddingStore, {});
1191
+ }
1192
+ }
1193
+ else if (res.status === "diverged") {
1194
+ logger.warn(`Startup sync: local history diverged (ahead ${res.ahead}, behind ${res.behind}). Started on local data — run sync_status / sync_docs to reconcile.`);
1195
+ }
1196
+ else if (res.status === "conflict") {
1197
+ logger.warn(`Startup sync: merge conflict in ${res.conflicted.length} file(s). Started on local data — resolve with resolve_sync_conflict.`);
1198
+ }
1199
+ else {
1200
+ logger.info("Startup sync: already up to date.");
1201
+ }
1202
+ }
1203
+ catch (err) {
1204
+ logger.error(`Startup sync failed (non-fatal): ${err?.message ?? err}`);
1205
+ }
1206
+ }
1125
1207
  async function main() {
1126
1208
  await migrateFolders(storageEngine);
1127
1209
  await migrateEmbeddings(embeddingStore, storageEngine);
1210
+ await syncPullOnStartup();
1128
1211
  const transport = new StdioServerTransport();
1129
1212
  await server.connect(transport);
1130
1213
  logger.info("MCP Server connected and running on stdio");
@@ -0,0 +1,17 @@
1
+ import type { SyncEngine } from "../core/git-sync.js";
2
+ import type { StorageEngine } from "../core/storage-engine.js";
3
+ import type { EmbeddingEngine } from "../core/embedding-engine.js";
4
+ import type { EmbeddingStore } from "../core/embedding-store.js";
5
+ type ToolResult = {
6
+ content: {
7
+ type: "text";
8
+ text: string;
9
+ }[];
10
+ isError?: boolean;
11
+ };
12
+ export declare function syncStatus(syncEngine: SyncEngine): Promise<ToolResult>;
13
+ export declare function pullDocs(syncEngine: SyncEngine, storageEngine: StorageEngine, embeddingEngine: EmbeddingEngine, embeddingStore: EmbeddingStore): Promise<ToolResult>;
14
+ export declare function pushDocs(syncEngine: SyncEngine, args: unknown): Promise<ToolResult>;
15
+ export declare function syncDocs(syncEngine: SyncEngine, storageEngine: StorageEngine, embeddingEngine: EmbeddingEngine, embeddingStore: EmbeddingStore, args: unknown): Promise<ToolResult>;
16
+ export declare function resolveSyncConflict(syncEngine: SyncEngine, args: unknown): Promise<ToolResult>;
17
+ export {};
@@ -0,0 +1,113 @@
1
+ import { z } from "zod";
2
+ import { reindexEmbeddings } from "./reindex-embeddings.js";
3
+ function ok(text) {
4
+ return { content: [{ type: "text", text }] };
5
+ }
6
+ function err(text) {
7
+ return { content: [{ type: "text", text }], isError: true };
8
+ }
9
+ const NOT_CONFIGURED = "Sync is not configured. Set MCP_DOCUMENTOR_SYNC_REMOTE (and MCP_DOCUMENTOR_SYNC_TOKEN) in the server environment.";
10
+ export async function syncStatus(syncEngine) {
11
+ try {
12
+ if (!syncEngine.isConfigured())
13
+ return ok(NOT_CONFIGURED);
14
+ const s = await syncEngine.status();
15
+ const lines = [
16
+ `Branch: ${s.branch}`,
17
+ `Ahead: ${s.ahead} commit(s) · Behind: ${s.behind} commit(s)`,
18
+ s.conflicted.length > 0 ? `Conflicted files (${s.conflicted.length}):\n - ${s.conflicted.join("\n - ")}` : "No conflicts.",
19
+ s.dirty.length > 0 ? `Uncommitted changes (${s.dirty.length}):\n - ${s.dirty.join("\n - ")}` : "Working tree clean.",
20
+ ];
21
+ return ok(lines.join("\n"));
22
+ }
23
+ catch (e) {
24
+ return err(`Error getting sync status: ${e instanceof Error ? e.message : String(e)}`);
25
+ }
26
+ }
27
+ function describePull(res) {
28
+ switch (res.status) {
29
+ case "up-to-date":
30
+ return "Already up to date — nothing to pull.";
31
+ case "updated":
32
+ return `Pulled ${res.changed.length} changed file(s):\n - ${res.changed.join("\n - ")}`;
33
+ case "diverged":
34
+ return `You have uncommitted local changes while the remote has ${res.behind} new commit(s). No changes applied — your local data is intact. Run push_docs to commit and publish your changes, then pull again to merge.`;
35
+ case "conflict":
36
+ return `Merge conflict in ${res.conflicted.length} file(s):\n - ${res.conflicted.join("\n - ")}\nEdit these markdown files to resolve, or use resolve_sync_conflict, then push_docs.`;
37
+ }
38
+ }
39
+ /** Reindex embeddings for new docs after a successful merge. Best-effort. */
40
+ async function reindexAfterPull(res, storageEngine, embeddingEngine, embeddingStore) {
41
+ if (res.status !== "updated" || res.changed.length === 0)
42
+ return "";
43
+ if (!embeddingEngine.isConfigured())
44
+ return "\n(Embeddings not reindexed: OpenAI key not configured.)";
45
+ try {
46
+ await reindexEmbeddings(storageEngine, embeddingEngine, embeddingStore, {});
47
+ return "\nEmbeddings reindexed for new documents.";
48
+ }
49
+ catch (e) {
50
+ return `\n(Embedding reindex failed: ${e instanceof Error ? e.message : String(e)})`;
51
+ }
52
+ }
53
+ export async function pullDocs(syncEngine, storageEngine, embeddingEngine, embeddingStore) {
54
+ try {
55
+ if (!syncEngine.isConfigured())
56
+ return err(NOT_CONFIGURED);
57
+ const res = await syncEngine.pull();
58
+ const reindexNote = await reindexAfterPull(res, storageEngine, embeddingEngine, embeddingStore);
59
+ return ok(describePull(res) + reindexNote);
60
+ }
61
+ catch (e) {
62
+ return err(`Error pulling docs: ${e instanceof Error ? e.message : String(e)}`);
63
+ }
64
+ }
65
+ const PushDocsArgs = z.object({ message: z.string().optional() });
66
+ export async function pushDocs(syncEngine, args) {
67
+ try {
68
+ if (!syncEngine.isConfigured())
69
+ return err(NOT_CONFIGURED);
70
+ const v = PushDocsArgs.parse(args);
71
+ const res = await syncEngine.push(v.message);
72
+ return ok(res.message);
73
+ }
74
+ catch (e) {
75
+ return err(`Error pushing docs: ${e instanceof Error ? e.message : String(e)}`);
76
+ }
77
+ }
78
+ export async function syncDocs(syncEngine, storageEngine, embeddingEngine, embeddingStore, args) {
79
+ try {
80
+ if (!syncEngine.isConfigured())
81
+ return err(NOT_CONFIGURED);
82
+ const v = PushDocsArgs.parse(args);
83
+ const pull = await syncEngine.pull();
84
+ const reindexNote = await reindexAfterPull(pull, storageEngine, embeddingEngine, embeddingStore);
85
+ // Don't push over a divergence/conflict — surface it so the user can resolve first.
86
+ if (pull.status === "diverged" || pull.status === "conflict") {
87
+ return ok(`Pull step:\n${describePull(pull)}${reindexNote}\n\nPush skipped until the above is resolved.`);
88
+ }
89
+ const push = await syncEngine.push(v.message);
90
+ return ok(`Pull step: ${describePull(pull)}${reindexNote}\n\nPush step: ${push.message}`);
91
+ }
92
+ catch (e) {
93
+ return err(`Error syncing docs: ${e instanceof Error ? e.message : String(e)}`);
94
+ }
95
+ }
96
+ const ResolveConflictArgs = z.object({
97
+ strategy: z.enum(["mine", "theirs"]),
98
+ paths: z.array(z.string()).optional(),
99
+ });
100
+ export async function resolveSyncConflict(syncEngine, args) {
101
+ try {
102
+ if (!syncEngine.isConfigured())
103
+ return err(NOT_CONFIGURED);
104
+ const v = ResolveConflictArgs.parse(args);
105
+ const resolved = await syncEngine.resolveConflict(v.strategy, v.paths);
106
+ if (resolved.length === 0)
107
+ return ok("No conflicted files to resolve.");
108
+ return ok(`Resolved ${resolved.length} file(s) keeping "${v.strategy}":\n - ${resolved.join("\n - ")}\nReview and run push_docs to finalize.`);
109
+ }
110
+ catch (e) {
111
+ return err(`Error resolving conflict: ${e instanceof Error ? e.message : String(e)}`);
112
+ }
113
+ }
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.56";
1
+ export declare const APP_VERSION = "1.0.57";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated version file - do not edit manually
2
- export const APP_VERSION = '1.0.56';
2
+ export const APP_VERSION = '1.0.57';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-documentor",
3
- "version": "1.0.56",
3
+ "version": "1.0.57",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1",
@@ -35,6 +35,7 @@
35
35
  "jszip": "^3.10.1",
36
36
  "openai": "^6.25.0",
37
37
  "puppeteer-core": "^25.0.4",
38
+ "simple-git": "^3.36.0",
38
39
  "zod": "^4.3.6"
39
40
  },
40
41
  "type": "module",