Skip to content

Commit a78c1c6

Browse files
committed
[release] select_appwrite_project binds projectDir; persisted projectId -> projectDir registry
select_appwrite_project now accepts a projectDir and routes config discovery through it instead of the frozen MCP startup CWD, so the MCP can hop between project trees without restarting. Mapping is persisted to ~/.appwrite/projects.json so a later projectId-only call rehydrates the dir across restarts. prefs.json is left untouched. - AuthResolver: ProjectOverride.projectDir, getEffectiveConfigDir(), setOverride/clearOverride reset the project-config cache. - New ProjectRegistry (~/.appwrite/projects.json, atomic writes). - Tool consumers read context.authResolver.getEffectiveConfigDir() instead of the static context.configDir. - list_appwrite_projects / get_config / get_auth_status surface the effective dir, the override's projectDir, and registered projects. - Tests for the registry, the override cache reset, and the handler (select-by-dir, rehydrate, conflict resolution, error paths).
1 parent b147c6a commit a78c1c6

9 files changed

Lines changed: 724 additions & 42 deletions

File tree

packages/appwrite-utils-mcp/src/auth/AuthResolver.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ export interface ProjectOverride {
9191
endpoint?: string;
9292
apiKey?: string;
9393
sessionCookie?: string;
94+
/**
95+
* Absolute path of the project's source directory. When set, the resolver
96+
* uses it as the effective working directory for config discovery instead
97+
* of the server's startup CWD. Lets one MCP server hop between project
98+
* trees without restarting from a different working directory.
99+
*/
100+
projectDir?: string;
94101
}
95102

96103
/**
@@ -165,16 +172,24 @@ export class AuthResolver {
165172
/**
166173
* Set the in-memory project override. Used by the select_appwrite_project
167174
* meta tool to pin the active project for this server instance.
175+
*
176+
* Resets the project-config cache so the next `getProjectConfig()` call
177+
* re-resolves against the new effective configDir — otherwise switching
178+
* projectDir mid-session would still return the previous project's config.
168179
*/
169180
public setOverride(override: ProjectOverride): void {
170181
this.sessionOverride = { ...override };
182+
this.projectConfigCache = { loaded: false };
183+
this.loggedMissingConfig = false;
171184
}
172185

173186
/**
174187
* Clear the in-memory project override.
175188
*/
176189
public clearOverride(): void {
177190
this.sessionOverride = null;
191+
this.projectConfigCache = { loaded: false };
192+
this.loggedMissingConfig = false;
178193
}
179194

180195
/**
@@ -185,21 +200,33 @@ export class AuthResolver {
185200
}
186201

187202
/**
188-
* Resolve the project config bound to this MCP's working directory. Cached
189-
* for the resolver lifetime. Returns `null` when no config exists in CWD.
203+
* The directory the resolver should treat as the project root for config
204+
* discovery. Override wins over server-default, server-default wins over
205+
* the live `process.cwd()` (defensive fallback for direct test
206+
* instantiation).
207+
*/
208+
public getEffectiveConfigDir(): string {
209+
return (
210+
this.sessionOverride?.projectDir ??
211+
this.serverDefaults.configDir ??
212+
process.cwd()
213+
);
214+
}
215+
216+
/**
217+
* Resolve the project config bound to this MCP's effective working
218+
* directory. Cached until the next `setOverride` / `clearOverride` call —
219+
* those reset the cache so the new projectDir takes effect.
190220
*/
191221
public async getProjectConfig(): Promise<ResolvedProjectConfig | null> {
192222
if (this.projectConfigCache.loaded) return this.projectConfigCache.config;
193-
// serverDefaults.configDir is always set by AppwriteMCPServer (snapshots
194-
// process.cwd() at construction). The `?? process.cwd()` here is a
195-
// defensive fallback only — direct AuthResolver instantiation in tests.
196-
const workingDir = this.serverDefaults.configDir ?? process.cwd();
223+
const workingDir = this.getEffectiveConfigDir();
197224
const config = await resolveProjectConfig(workingDir);
198225
this.projectConfigCache = { config, loaded: true };
199226
if (!config && !this.loggedMissingConfig) {
200227
this.loggedMissingConfig = true;
201228
console.error(
202-
`[appwrite-mcp] no appwrite config found from workingDir=${workingDir} — set --configDir, --endpoint+--projectId, or run from inside a project tree`
229+
`[appwrite-mcp] no appwrite config found from workingDir=${workingDir} — set --configDir, --endpoint+--projectId, run from inside a project tree, or call select_appwrite_project with a projectDir`
203230
);
204231
}
205232
return config;

packages/appwrite-utils-mcp/src/server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { z } from 'zod';
1515
import { parseFlags, getEnabledToolGroups, type ServerFlags } from './config/FlagParser.js';
1616
import { AuthResolver } from './auth/AuthResolver.js';
1717
import { ClientRegistry } from './state/ClientRegistry.js';
18+
import { ProjectRegistry } from './state/ProjectRegistry.js';
1819
import { StateManager } from './state/StateManager.js';
1920
import { ToolRegistry } from './tools/ToolRegistry.js';
2021
import type { ToolContext } from './tools/ToolGroup.js';
@@ -62,6 +63,7 @@ export class AppwriteMCPServer {
6263
// Core components
6364
private readonly authResolver: AuthResolver;
6465
private readonly clientRegistry: ClientRegistry;
66+
private readonly projectRegistry: ProjectRegistry;
6567
private readonly stateManager: StateManager;
6668
private readonly toolRegistry: ToolRegistry;
6769

@@ -103,6 +105,7 @@ export class AppwriteMCPServer {
103105
});
104106

105107
this.clientRegistry = new ClientRegistry();
108+
this.projectRegistry = new ProjectRegistry();
106109
this.stateManager = new StateManager();
107110

108111
// Meta tools are always-on except in locked-scope mode (per-group bins).
@@ -310,9 +313,9 @@ Server instance ID: ${this.instanceId}
310313
const context: ToolContext = {
311314
authResolver: this.authResolver,
312315
clientRegistry: this.clientRegistry,
316+
projectRegistry: this.projectRegistry,
313317
stateManager: this.stateManager,
314318
projectId: this.flags.projectId,
315-
configDir: this.flags.configDir,
316319
toolRegistry: this.toolRegistry,
317320
notifyToolsChanged: this.flags.lockedScope
318321
? undefined
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* Persistent registry mapping Appwrite project IDs to the absolute project
3+
* directory (the dir containing that project's `appwriteConfig.yaml` /
4+
* `appwrite.json` / etc.).
5+
*
6+
* Backed by `~/.appwrite/projects.json` — sibling to the Appwrite CLI's
7+
* `prefs.json`, but written and read independently. This module MUST NOT read
8+
* or write `prefs.json` — that file is owned by the upstream Appwrite CLI and
9+
* touching it risks corrupting the user's CLI session state.
10+
*
11+
* Schema:
12+
* ```jsonc
13+
* {
14+
* "<projectId>": {
15+
* "projectDir": "/abs/path/to/project",
16+
* "endpoint": "https://cloud.appwrite.io/v1", // optional
17+
* "lastSelectedAt": "2026-05-29T12:34:56.789Z"
18+
* }
19+
* }
20+
* ```
21+
*
22+
* @packageDocumentation
23+
*/
24+
25+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
26+
import { dirname, join } from "node:path";
27+
import { homedir } from "node:os";
28+
import { randomBytes } from "node:crypto";
29+
30+
export interface ProjectRegistryEntry {
31+
projectDir: string;
32+
endpoint?: string;
33+
lastSelectedAt: string;
34+
}
35+
36+
export interface ProjectRegistrySnapshot {
37+
[projectId: string]: ProjectRegistryEntry;
38+
}
39+
40+
/**
41+
* Default location: `~/.appwrite/projects.json`. Lives alongside prefs.json
42+
* but is fully independent — the Appwrite CLI never reads or writes it.
43+
*/
44+
function defaultRegistryPath(): string {
45+
return join(homedir(), ".appwrite", "projects.json");
46+
}
47+
48+
/**
49+
* Persistent project-directory registry. Safe for concurrent MCP instances:
50+
* writes go through an atomic tmp-file rename so a crash mid-write can't
51+
* leave a half-written JSON file. Reads tolerate a missing or corrupt file
52+
* (treated as empty) — losing the registry is annoying, not catastrophic.
53+
*/
54+
export class ProjectRegistry {
55+
private readonly filePath: string;
56+
private cache: ProjectRegistrySnapshot | null = null;
57+
58+
constructor(filePath: string = defaultRegistryPath()) {
59+
this.filePath = filePath;
60+
}
61+
62+
/**
63+
* Where the registry is persisted on disk. Surfaced for debugging /
64+
* `list_appwrite_projects` output.
65+
*/
66+
public getFilePath(): string {
67+
return this.filePath;
68+
}
69+
70+
/**
71+
* Load the registry from disk. Cached after first call. A missing or
72+
* corrupt file becomes an empty snapshot — never throws.
73+
*/
74+
public async load(): Promise<ProjectRegistrySnapshot> {
75+
if (this.cache) return this.cache;
76+
try {
77+
const raw = await readFile(this.filePath, "utf-8");
78+
const parsed = JSON.parse(raw) as unknown;
79+
this.cache = normalize(parsed);
80+
} catch {
81+
this.cache = {};
82+
}
83+
return this.cache;
84+
}
85+
86+
/**
87+
* Look up the registry entry for a project ID. Returns null when unknown.
88+
*/
89+
public async get(projectId: string): Promise<ProjectRegistryEntry | null> {
90+
if (!projectId) return null;
91+
const snapshot = await this.load();
92+
const entry = snapshot[projectId];
93+
return entry ? { ...entry } : null;
94+
}
95+
96+
/**
97+
* Upsert an entry for a project ID. Always stamps `lastSelectedAt` with
98+
* the current ISO timestamp. Persists atomically to disk.
99+
*/
100+
public async set(
101+
projectId: string,
102+
entry: Omit<ProjectRegistryEntry, "lastSelectedAt"> & {
103+
lastSelectedAt?: string;
104+
}
105+
): Promise<ProjectRegistryEntry> {
106+
if (!projectId) throw new Error("ProjectRegistry.set: projectId is required");
107+
if (!entry.projectDir) {
108+
throw new Error("ProjectRegistry.set: projectDir is required");
109+
}
110+
const snapshot = await this.load();
111+
const next: ProjectRegistryEntry = {
112+
projectDir: entry.projectDir,
113+
endpoint: entry.endpoint,
114+
lastSelectedAt: entry.lastSelectedAt ?? new Date().toISOString(),
115+
};
116+
snapshot[projectId] = next;
117+
this.cache = snapshot;
118+
await this.persist(snapshot);
119+
return { ...next };
120+
}
121+
122+
/**
123+
* Remove an entry for a project ID. No-op when the entry doesn't exist.
124+
*/
125+
public async remove(projectId: string): Promise<boolean> {
126+
if (!projectId) return false;
127+
const snapshot = await this.load();
128+
if (!(projectId in snapshot)) return false;
129+
delete snapshot[projectId];
130+
this.cache = snapshot;
131+
await this.persist(snapshot);
132+
return true;
133+
}
134+
135+
/**
136+
* Return every registered project as a flat array, sorted by most-recently
137+
* selected first.
138+
*/
139+
public async list(): Promise<
140+
Array<ProjectRegistryEntry & { projectId: string }>
141+
> {
142+
const snapshot = await this.load();
143+
return Object.entries(snapshot)
144+
.map(([projectId, entry]) => ({ projectId, ...entry }))
145+
.sort((a, b) => (b.lastSelectedAt ?? "").localeCompare(a.lastSelectedAt ?? ""));
146+
}
147+
148+
/**
149+
* Drop the in-memory cache. Next read will hit disk. Useful in tests so
150+
* one test's writes don't leak into the next.
151+
*/
152+
public invalidate(): void {
153+
this.cache = null;
154+
}
155+
156+
/**
157+
* Write the snapshot to disk via tmp-file + rename so a crash mid-write
158+
* can't leave a half-written JSON file. Creates the parent directory if
159+
* needed (mode 0700 since prefs/projects can contain endpoint strings).
160+
*/
161+
private async persist(snapshot: ProjectRegistrySnapshot): Promise<void> {
162+
const dir = dirname(this.filePath);
163+
await mkdir(dir, { recursive: true, mode: 0o700 });
164+
const tmpPath = `${this.filePath}.${randomBytes(6).toString("hex")}.tmp`;
165+
const body = `${JSON.stringify(snapshot, null, 2)}\n`;
166+
await writeFile(tmpPath, body, { mode: 0o600 });
167+
await rename(tmpPath, this.filePath);
168+
}
169+
}
170+
171+
/**
172+
* Coerce arbitrary parsed JSON into a valid registry snapshot. Drops entries
173+
* that don't have a string `projectDir`. Never throws.
174+
*/
175+
function normalize(parsed: unknown): ProjectRegistrySnapshot {
176+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
177+
const out: ProjectRegistrySnapshot = {};
178+
for (const [pid, raw] of Object.entries(parsed as Record<string, unknown>)) {
179+
if (!raw || typeof raw !== "object") continue;
180+
const entry = raw as {
181+
projectDir?: unknown;
182+
endpoint?: unknown;
183+
lastSelectedAt?: unknown;
184+
};
185+
if (typeof entry.projectDir !== "string" || !entry.projectDir) continue;
186+
out[pid] = {
187+
projectDir: entry.projectDir,
188+
endpoint:
189+
typeof entry.endpoint === "string" && entry.endpoint ? entry.endpoint : undefined,
190+
lastSelectedAt:
191+
typeof entry.lastSelectedAt === "string" && entry.lastSelectedAt
192+
? entry.lastSelectedAt
193+
: new Date(0).toISOString(),
194+
};
195+
}
196+
return out;
197+
}

packages/appwrite-utils-mcp/src/tools/ToolGroup.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import type { z } from 'zod';
77
import type { StateManager } from '../state/StateManager.js';
88
import type { ClientRegistry } from '../state/ClientRegistry.js';
9+
import type { ProjectRegistry } from '../state/ProjectRegistry.js';
910
import type { AuthResolver } from '../auth/AuthResolver.js';
1011
import type { ToolRegistry } from './ToolRegistry.js';
1112

@@ -22,11 +23,13 @@ export interface ToolContext {
2223
/** Optional project ID for the current operation */
2324
projectId?: string;
2425
/**
25-
* Directory the server should treat as the "current project" when
26-
* resolving config files (appwrite.json) and function bundle paths.
27-
* Falls back to process.cwd() when unset.
26+
* Persistent projectId -> projectDir registry backed by
27+
* ~/.appwrite/projects.json. Lets `select_appwrite_project` rehydrate a
28+
* project directory across MCP restarts when only a projectId is given.
29+
* Optional so locked-scope binaries that don't expose meta tools don't
30+
* need to plumb one through.
2831
*/
29-
configDir?: string;
32+
projectRegistry?: ProjectRegistry;
3033
/** Tool registry — present for meta tools that mutate the enabled-group set */
3134
toolRegistry?: ToolRegistry;
3235
/** Send notifications/tools/list_changed to the MCP client */

packages/appwrite-utils-mcp/src/tools/config/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ async function getConfig(
5454
? {
5555
projectId: overrideRaw.projectId,
5656
endpoint: overrideRaw.endpoint || null,
57+
projectDir: overrideRaw.projectDir || null,
5758
hasApiKey: !!overrideRaw.apiKey,
5859
hasCookie: !!overrideRaw.sessionCookie,
5960
}
@@ -123,6 +124,7 @@ async function getConfig(
123124
configDir: serverDefaults.configDir || null,
124125
hasApiKey: !!serverDefaults.apiKey,
125126
},
127+
effectiveConfigDir: context.authResolver.getEffectiveConfigDir(),
126128
cwdProject,
127129
sessionOverride,
128130
prefsCurrent,
@@ -150,7 +152,9 @@ async function validateConfig(
150152
if (!actualConfigPath) {
151153
const { ConfigDiscoveryService } = await import("appwrite-utils-helpers");
152154
const discoveryService = new ConfigDiscoveryService();
153-
const discovered = await discoveryService.findConfig(context.configDir ?? process.cwd());
155+
const discovered = await discoveryService.findConfig(
156+
context.authResolver.getEffectiveConfigDir()
157+
);
154158
if (!discovered) {
155159
throw new Error("No Appwrite configuration file found. Please provide a configPath or ensure appwrite.json exists.");
156160
}
@@ -239,6 +243,7 @@ async function getAuthStatus(
239243
? {
240244
projectId: overrideRaw.projectId,
241245
endpoint: overrideRaw.endpoint || null,
246+
projectDir: overrideRaw.projectDir || null,
242247
hasApiKey: !!overrideRaw.apiKey,
243248
hasCookie: !!overrideRaw.sessionCookie,
244249
}
@@ -276,6 +281,7 @@ async function getAuthStatus(
276281
hasApiKey: !!serverDefaults.apiKey,
277282
configDir: serverDefaults.configDir || null,
278283
},
284+
effectiveConfigDir: context.authResolver.getEffectiveConfigDir(),
279285
cwdProject,
280286
sessionOverride,
281287
};

packages/appwrite-utils-mcp/src/tools/functions/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,7 @@ async function handleDeployFunction(
513513
if (!functionPath) {
514514
// Try to find function directory automatically
515515
const foundPath = await functionManager.findFunctionDirectory(fn.name, {
516-
searchPaths: [context.configDir ?? process.cwd()],
516+
searchPaths: [context.authResolver.getEffectiveConfigDir()],
517517
verbose: false,
518518
});
519519

@@ -685,7 +685,7 @@ async function handleDeployFunctions(
685685
for (const fnCfg of selected) {
686686
try {
687687
const foundPath = await functionManager.findFunctionDirectory(fnCfg.name, {
688-
searchPaths: [context.configDir ?? process.cwd()],
688+
searchPaths: [context.authResolver.getEffectiveConfigDir()],
689689
verbose: false,
690690
});
691691
if (!foundPath) {

0 commit comments

Comments
 (0)