mcp-memory-bucket 0.8.1 → 0.8.2
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/dist/client/assets/{index-Y4C-4HnN.js → index-_w_b_MQl.js} +90 -88
- package/dist/client/index.html +1 -1
- package/dist/src/config.js +23 -5
- package/dist/src/memory/repository.js +34 -3
- package/dist/src/remote/identity.js +67 -0
- package/dist/src/server.js +5 -3
- package/dist/src/skills/repository.js +36 -3
- package/dist/src/web/routes.js +45 -4
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
package/dist/src/config.js
CHANGED
|
@@ -5,9 +5,18 @@ const FOLDERFOO_CLOUD_HOST = 'https://files.cuul.cc';
|
|
|
5
5
|
function isRemoteEntry(entry) {
|
|
6
6
|
return typeof entry === 'object' && 'remote' in entry;
|
|
7
7
|
}
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Local mirror directory a remote source's content is synced into - see the
|
|
10
|
+
* RemoteFolder doc comment above. Nested under a {mode}_{username} segment
|
|
11
|
+
* so mirrors from different folderfoo deployments/logins never collide or
|
|
12
|
+
* get mistaken for one another (see identity.ts) - a mirror connected while
|
|
13
|
+
* logged into "dev" as "anatoli" lands at
|
|
14
|
+
* .memory-bucket-remote-cache/dev_anatoli/<name>/, distinct from the same
|
|
15
|
+
* folder name connected under "cloud" or a different username.
|
|
16
|
+
*/
|
|
17
|
+
export function mirrorDirFor(baseDir, mode, username, name) {
|
|
18
|
+
const identitySegment = sanitizeFolderName(`${mode}_${username}`);
|
|
19
|
+
return path.join(baseDir, '.memory-bucket-remote-cache', identitySegment, sanitizeFolderName(name));
|
|
11
20
|
}
|
|
12
21
|
function memoryDirFlag(argv) {
|
|
13
22
|
const idx = argv.indexOf('--memory-dir');
|
|
@@ -55,7 +64,7 @@ function resolveFolders(entries, baseDir) {
|
|
|
55
64
|
return { name: nameFromPath(entry), path: path.resolve(baseDir, entry) };
|
|
56
65
|
}
|
|
57
66
|
if (isRemoteEntry(entry)) {
|
|
58
|
-
const mirrorDir = mirrorDirFor(baseDir, entry.name);
|
|
67
|
+
const mirrorDir = mirrorDirFor(baseDir, entry.remote.mode, entry.remote.username, entry.name);
|
|
59
68
|
remoteFolders.push({ name: entry.name, ...entry.remote, mirrorDir });
|
|
60
69
|
return { name: entry.name, path: mirrorDir };
|
|
61
70
|
}
|
|
@@ -130,7 +139,16 @@ export function saveRemoteFolder(config, kind, entry) {
|
|
|
130
139
|
...current,
|
|
131
140
|
[key]: [
|
|
132
141
|
...existing,
|
|
133
|
-
{
|
|
142
|
+
{
|
|
143
|
+
name: entry.name,
|
|
144
|
+
remote: {
|
|
145
|
+
server: entry.server,
|
|
146
|
+
tenantId: entry.tenantId,
|
|
147
|
+
folderPath: entry.folderPath,
|
|
148
|
+
mode: entry.mode,
|
|
149
|
+
username: entry.username,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
134
152
|
],
|
|
135
153
|
};
|
|
136
154
|
fs.writeFileSync(config.configPath, JSON.stringify(next, null, 2) + '\n');
|
|
@@ -11,6 +11,7 @@ import { applyBodyEdits } from '../shared/body-edits.js';
|
|
|
11
11
|
import { attachmentsDirFor } from '../attachments/storage.js';
|
|
12
12
|
import { normalizeKey } from '../types.js';
|
|
13
13
|
import { readFile as readRemoteFile, writeFile as writeRemoteFile, joinRemoteFolderPath } from '../remote/folderfoo-client.js';
|
|
14
|
+
import { isFolderVisible } from '../remote/identity.js';
|
|
14
15
|
/** Uppercases and strips everything but letters/digits — used to compare keys that differ only in
|
|
15
16
|
* punctuation/whitespace formatting (e.g. `RMXS-15` and `RMXS15` strip to the same `RMXS15`). */
|
|
16
17
|
export function stripKey(s) {
|
|
@@ -40,19 +41,37 @@ export class MemoryRepository {
|
|
|
40
41
|
folders;
|
|
41
42
|
remoteFolders;
|
|
42
43
|
credentialsBaseDir;
|
|
44
|
+
identity;
|
|
43
45
|
syncSpec;
|
|
44
46
|
watcher;
|
|
45
|
-
constructor(db, folders, remoteFolders = [], credentialsBaseDir) {
|
|
47
|
+
constructor(db, folders, remoteFolders = [], credentialsBaseDir, identity) {
|
|
46
48
|
this.db = db;
|
|
47
49
|
this.folders = folders;
|
|
48
50
|
this.remoteFolders = remoteFolders;
|
|
49
51
|
this.credentialsBaseDir = credentialsBaseDir;
|
|
52
|
+
this.identity = identity;
|
|
50
53
|
this.syncSpec = memorySyncSpec(folders);
|
|
51
54
|
}
|
|
52
55
|
/** The RemoteFolder a NamedFolder name resolves to, or undefined for a local (non-remote) folder. */
|
|
53
56
|
remoteFor(folderName) {
|
|
54
57
|
return this.remoteFolders.find((f) => f.name === folderName);
|
|
55
58
|
}
|
|
59
|
+
/** Whether `folderName` should be visible right now — always true for local folders; for a remote folder, only when it matches the current login (see identity.ts). No identity tracker configured means folderfoo integration is off entirely, so nothing remote is ever visible. */
|
|
60
|
+
isFolderNameVisible(folderName) {
|
|
61
|
+
const remote = this.remoteFor(folderName);
|
|
62
|
+
if (!remote)
|
|
63
|
+
return true;
|
|
64
|
+
if (!this.identity)
|
|
65
|
+
return false;
|
|
66
|
+
return isFolderVisible(remote, this.identity.current());
|
|
67
|
+
}
|
|
68
|
+
/** Names of every remote folder NOT matching the current identity — used to exclude their rows from list/search SQL. */
|
|
69
|
+
hiddenFolderNames() {
|
|
70
|
+
if (!this.identity)
|
|
71
|
+
return this.remoteFolders.map((f) => f.name);
|
|
72
|
+
const identity = this.identity.current();
|
|
73
|
+
return this.remoteFolders.filter((f) => !isFolderVisible(f, identity)).map((f) => f.name);
|
|
74
|
+
}
|
|
56
75
|
/** Attaches the live chokidar watcher so addFolder/removeFolder can mutate it without a restart. */
|
|
57
76
|
setWatcher(watcher) {
|
|
58
77
|
this.watcher = watcher;
|
|
@@ -64,9 +83,9 @@ export class MemoryRepository {
|
|
|
64
83
|
listRemoteFolders() {
|
|
65
84
|
return [...this.remoteFolders];
|
|
66
85
|
}
|
|
67
|
-
/** Same as listFolders(), but tags each entry with whether it's a remote (folderfoo) source — for the web UI's folder list, e.g. to render remote folders in a distinct color. */
|
|
86
|
+
/** Same as listFolders(), but tags each entry with whether it's a remote (folderfoo) source — for the web UI's folder list, e.g. to render remote folders in a distinct color. Excludes remote folders that don't match the current login (see identity.ts). */
|
|
68
87
|
listFoldersWithRemoteInfo() {
|
|
69
|
-
return this.folders.map((f) => ({ ...f, remote: !!this.remoteFor(f.name) }));
|
|
88
|
+
return this.folders.filter((f) => this.isFolderNameVisible(f.name)).map((f) => ({ ...f, remote: !!this.remoteFor(f.name) }));
|
|
70
89
|
}
|
|
71
90
|
resolveFolder(folderName) {
|
|
72
91
|
if (folderName) {
|
|
@@ -134,6 +153,11 @@ export class MemoryRepository {
|
|
|
134
153
|
if (!opts.includePaused) {
|
|
135
154
|
conditions.push('paused = 0');
|
|
136
155
|
}
|
|
156
|
+
const hidden = this.hiddenFolderNames();
|
|
157
|
+
if (hidden.length > 0) {
|
|
158
|
+
conditions.push(`folder NOT IN (${hidden.map(() => '?').join(', ')})`);
|
|
159
|
+
params.push(...hidden);
|
|
160
|
+
}
|
|
137
161
|
const rows = this.db
|
|
138
162
|
.prepare(`SELECT * FROM memory_docs WHERE ${conditions.join(' AND ')}`)
|
|
139
163
|
.all(...params);
|
|
@@ -168,6 +192,11 @@ export class MemoryRepository {
|
|
|
168
192
|
if (!includePaused) {
|
|
169
193
|
conditions.push('m.paused = 0');
|
|
170
194
|
}
|
|
195
|
+
const hidden = this.hiddenFolderNames();
|
|
196
|
+
if (hidden.length > 0) {
|
|
197
|
+
conditions.push(`m.folder NOT IN (${hidden.map(() => '?').join(', ')})`);
|
|
198
|
+
params.push(...hidden);
|
|
199
|
+
}
|
|
171
200
|
params.push(limit, offset);
|
|
172
201
|
try {
|
|
173
202
|
const rows = this.db
|
|
@@ -198,6 +227,8 @@ export class MemoryRepository {
|
|
|
198
227
|
if (!row)
|
|
199
228
|
return null;
|
|
200
229
|
const doc = rowToDoc(row);
|
|
230
|
+
if (!this.isFolderNameVisible(doc.folder))
|
|
231
|
+
return null;
|
|
201
232
|
const remote = this.remoteFor(doc.folder);
|
|
202
233
|
if (!remote || !this.credentialsBaseDir)
|
|
203
234
|
return doc;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decodes the `username` claim out of a folderfoo JWT's payload — no
|
|
3
|
+
* signature verification, since this only reads a claim from a token the
|
|
4
|
+
* server already trusts enough to use against folderfoo itself (see
|
|
5
|
+
* credentials.ts). folderfoo's signToken() payload shape is
|
|
6
|
+
* {username, fullname, origIat, iat, exp} (folderfoo's own server.js) — no
|
|
7
|
+
* sub/id/email/tenantId claim exists, so username is the stable identity key.
|
|
8
|
+
*/
|
|
9
|
+
export function decodeUsername(jwt) {
|
|
10
|
+
const payloadSegment = jwt.split('.')[1];
|
|
11
|
+
if (!payloadSegment)
|
|
12
|
+
throw new Error('malformed folderfoo token: missing payload segment');
|
|
13
|
+
const payload = JSON.parse(Buffer.from(payloadSegment, 'base64').toString('utf-8'));
|
|
14
|
+
if (!payload.username)
|
|
15
|
+
throw new Error('malformed folderfoo token: missing username claim');
|
|
16
|
+
return payload.username;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Process-wide "who's currently logged in" tracker. `mode` is fixed for the
|
|
20
|
+
* process's lifetime (only changes via a restart with a different
|
|
21
|
+
* --folderfoo-mode flag, per the settled design) - only `username` is
|
|
22
|
+
* mutable, driven by the browser's login/logout signals via
|
|
23
|
+
* /api/folderfoo/login and /api/folderfoo/logout. There is no per-tab or
|
|
24
|
+
* per-MCP-client identity: multiple browser tabs and MCP clients share this
|
|
25
|
+
* one process-wide value, and the most recent login anywhere wins for all of
|
|
26
|
+
* them (settled design decision — not a per-connection scope).
|
|
27
|
+
*/
|
|
28
|
+
export class IdentityTracker {
|
|
29
|
+
mode;
|
|
30
|
+
username = null;
|
|
31
|
+
listeners = new Set();
|
|
32
|
+
constructor(mode) {
|
|
33
|
+
this.mode = mode;
|
|
34
|
+
}
|
|
35
|
+
current() {
|
|
36
|
+
return { mode: this.mode, username: this.username };
|
|
37
|
+
}
|
|
38
|
+
setUsername(username) {
|
|
39
|
+
if (this.username === username)
|
|
40
|
+
return;
|
|
41
|
+
this.username = username;
|
|
42
|
+
this.emit();
|
|
43
|
+
}
|
|
44
|
+
clearUsername() {
|
|
45
|
+
if (this.username === null)
|
|
46
|
+
return;
|
|
47
|
+
this.username = null;
|
|
48
|
+
this.emit();
|
|
49
|
+
}
|
|
50
|
+
onIdentityChange(cb) {
|
|
51
|
+
this.listeners.add(cb);
|
|
52
|
+
return () => this.listeners.delete(cb);
|
|
53
|
+
}
|
|
54
|
+
emit() {
|
|
55
|
+
const identity = this.current();
|
|
56
|
+
for (const cb of this.listeners)
|
|
57
|
+
cb(identity);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** True when `folder` was connected under the identity currently logged in. */
|
|
61
|
+
export function matchesCurrentIdentity(folder, identity) {
|
|
62
|
+
return identity.username !== null && folder.mode === identity.mode && folder.username === identity.username;
|
|
63
|
+
}
|
|
64
|
+
/** Local (non-remote) folders are always visible; a remote folder only when it matches the current identity. */
|
|
65
|
+
export function isFolderVisible(remote, identity) {
|
|
66
|
+
return remote === undefined || matchesCurrentIdentity(remote, identity);
|
|
67
|
+
}
|
package/dist/src/server.js
CHANGED
|
@@ -20,6 +20,7 @@ import { registerUiTool } from './web/ui-tool.js';
|
|
|
20
20
|
import { registerMemoryChannelTools } from './channels/tools.js';
|
|
21
21
|
import { startChannelSweep } from './channels/store.js';
|
|
22
22
|
import { startRemotePolling } from './remote/remote-sync.js';
|
|
23
|
+
import { IdentityTracker } from './remote/identity.js';
|
|
23
24
|
// server.ts is rebuilt from `buildMcpServer()` on every /mcp request (see below),
|
|
24
25
|
// so tool schemas (which conditionally include `folder` based on folder count) always
|
|
25
26
|
// reflect the current folders — no restart needed after an add/remove-folder call.
|
|
@@ -36,14 +37,15 @@ const builtinSkillsDir = path.join(__dirname, 'skills', 'builtin');
|
|
|
36
37
|
const PORT = process.env.PORT ? Number(process.env.PORT) : 8767;
|
|
37
38
|
const config = loadConfig();
|
|
38
39
|
const db = openCache(config.cacheDbPath);
|
|
40
|
+
const identity = new IdentityTracker(config.folderfooMode);
|
|
39
41
|
const skillSpec = skillSyncSpec([{ name: 'builtin', path: builtinSkillsDir }, ...config.skillFolders]);
|
|
40
42
|
const memorySpec = memorySyncSpec(config.memoryFolders);
|
|
41
43
|
initialScan(db, skillSpec);
|
|
42
44
|
initialScan(db, memorySpec);
|
|
43
45
|
const skillWatcher = watchSources(db, skillSpec);
|
|
44
46
|
const memoryWatcher = watchSources(db, memorySpec);
|
|
45
|
-
const skillRepo = new SkillRepository(db, [{ name: 'builtin', path: builtinSkillsDir }, ...config.skillFolders], config.remoteSkillFolders, config.baseDir);
|
|
46
|
-
const memoryRepo = new MemoryRepository(db, config.memoryFolders, config.remoteMemoryFolders, config.baseDir);
|
|
47
|
+
const skillRepo = new SkillRepository(db, [{ name: 'builtin', path: builtinSkillsDir }, ...config.skillFolders], config.remoteSkillFolders, config.baseDir, identity);
|
|
48
|
+
const memoryRepo = new MemoryRepository(db, config.memoryFolders, config.remoteMemoryFolders, config.baseDir, identity);
|
|
47
49
|
skillRepo.setWatcher(skillWatcher);
|
|
48
50
|
memoryRepo.setWatcher(memoryWatcher);
|
|
49
51
|
const attachmentRepo = new AttachmentRepository(memoryRepo, skillRepo);
|
|
@@ -77,7 +79,7 @@ function buildMcpServer() {
|
|
|
77
79
|
startChannelSweep((name) => console.error(`[memory-bucket] sweeping idle memory channel: ${name}`));
|
|
78
80
|
const app = express();
|
|
79
81
|
app.use(express.json());
|
|
80
|
-
app.use(buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, memorySpec, { skill: remoteSkillPoller, memory: remoteMemoryPoller }));
|
|
82
|
+
app.use(buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, memorySpec, identity, { skill: remoteSkillPoller, memory: remoteMemoryPoller }));
|
|
81
83
|
app.use(express.static(path.join(packageRoot, 'dist', 'client')));
|
|
82
84
|
app.post('/mcp', async (req, res) => {
|
|
83
85
|
const server = buildMcpServer();
|
|
@@ -8,6 +8,7 @@ import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, skillSyncSp
|
|
|
8
8
|
import { SearchQueryError, sanitizeFtsQuery } from '../store/search.js';
|
|
9
9
|
import { applyBodyEdits } from '../shared/body-edits.js';
|
|
10
10
|
import { readFile as readRemoteFile, writeFile as writeRemoteFile, joinRemoteFolderPath } from '../remote/folderfoo-client.js';
|
|
11
|
+
import { isFolderVisible } from '../remote/identity.js';
|
|
11
12
|
function rowToDoc(row) {
|
|
12
13
|
return {
|
|
13
14
|
name: row.id,
|
|
@@ -29,20 +30,38 @@ export class SkillRepository {
|
|
|
29
30
|
folders;
|
|
30
31
|
remoteFolders;
|
|
31
32
|
credentialsBaseDir;
|
|
33
|
+
identity;
|
|
32
34
|
syncSpec;
|
|
33
35
|
watcher;
|
|
34
36
|
/** `folders[0]` is always the builtin skills dir — never exposed for create()/removal. */
|
|
35
|
-
constructor(db, folders, remoteFolders = [], credentialsBaseDir) {
|
|
37
|
+
constructor(db, folders, remoteFolders = [], credentialsBaseDir, identity) {
|
|
36
38
|
this.db = db;
|
|
37
39
|
this.folders = folders;
|
|
38
40
|
this.remoteFolders = remoteFolders;
|
|
39
41
|
this.credentialsBaseDir = credentialsBaseDir;
|
|
42
|
+
this.identity = identity;
|
|
40
43
|
this.syncSpec = skillSyncSpec(folders);
|
|
41
44
|
}
|
|
42
45
|
/** The RemoteFolder a NamedFolder name resolves to, or undefined for a local (non-remote) folder. */
|
|
43
46
|
remoteFor(folderName) {
|
|
44
47
|
return this.remoteFolders.find((f) => f.name === folderName);
|
|
45
48
|
}
|
|
49
|
+
/** Whether `folderName` should be visible right now — always true for local folders; for a remote folder, only when it matches the current login (see identity.ts). No identity tracker configured means folderfoo integration is off entirely, so nothing remote is ever visible. */
|
|
50
|
+
isFolderNameVisible(folderName) {
|
|
51
|
+
const remote = this.remoteFor(folderName);
|
|
52
|
+
if (!remote)
|
|
53
|
+
return true;
|
|
54
|
+
if (!this.identity)
|
|
55
|
+
return false;
|
|
56
|
+
return isFolderVisible(remote, this.identity.current());
|
|
57
|
+
}
|
|
58
|
+
/** Names of every remote folder NOT matching the current identity — used to exclude their rows from list/search SQL. */
|
|
59
|
+
hiddenFolderNames() {
|
|
60
|
+
if (!this.identity)
|
|
61
|
+
return this.remoteFolders.map((f) => f.name);
|
|
62
|
+
const identity = this.identity.current();
|
|
63
|
+
return this.remoteFolders.filter((f) => !isFolderVisible(f, identity)).map((f) => f.name);
|
|
64
|
+
}
|
|
46
65
|
/** Pushes the just-written SKILL.md at filePath to folderfoo, if targetFolderName resolves to a remote source. No-op for a local folder. See get()'s comment on the folderPath/name split for a skill's directory-per-skill layout. */
|
|
47
66
|
async pushToRemoteIfNeeded(targetFolderName, filePath) {
|
|
48
67
|
const remote = this.remoteFor(targetFolderName);
|
|
@@ -64,9 +83,11 @@ export class SkillRepository {
|
|
|
64
83
|
listRemoteFolders() {
|
|
65
84
|
return [...this.remoteFolders];
|
|
66
85
|
}
|
|
67
|
-
/** Same as listFolders(), but tags each entry with whether it's a remote (folderfoo) source — for the web UI's folder list, e.g. to render remote folders in a distinct color. */
|
|
86
|
+
/** Same as listFolders(), but tags each entry with whether it's a remote (folderfoo) source — for the web UI's folder list, e.g. to render remote folders in a distinct color. Excludes remote folders that don't match the current login (see identity.ts). */
|
|
68
87
|
listFoldersWithRemoteInfo() {
|
|
69
|
-
return this.listFolders()
|
|
88
|
+
return this.listFolders()
|
|
89
|
+
.filter((f) => this.isFolderNameVisible(f.name))
|
|
90
|
+
.map((f) => ({ ...f, remote: !!this.remoteFor(f.name) }));
|
|
70
91
|
}
|
|
71
92
|
resolveFolder(folderName) {
|
|
72
93
|
const userFolders = this.listFolders();
|
|
@@ -131,6 +152,11 @@ export class SkillRepository {
|
|
|
131
152
|
if (!opts.includePaused) {
|
|
132
153
|
conditions.push('paused = 0');
|
|
133
154
|
}
|
|
155
|
+
const hidden = this.hiddenFolderNames();
|
|
156
|
+
if (hidden.length > 0) {
|
|
157
|
+
conditions.push(`folder NOT IN (${hidden.map(() => '?').join(', ')})`);
|
|
158
|
+
params.push(...hidden);
|
|
159
|
+
}
|
|
134
160
|
const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
|
|
135
161
|
const rows = this.db
|
|
136
162
|
.prepare(`SELECT id, description, owner, status, tags, trigger_phrases, folder, paused FROM skills${where}`)
|
|
@@ -182,6 +208,11 @@ export class SkillRepository {
|
|
|
182
208
|
if (!includePaused) {
|
|
183
209
|
conditions.push('s.paused = 0');
|
|
184
210
|
}
|
|
211
|
+
const hidden = this.hiddenFolderNames();
|
|
212
|
+
if (hidden.length > 0) {
|
|
213
|
+
conditions.push(`s.folder NOT IN (${hidden.map(() => '?').join(', ')})`);
|
|
214
|
+
params.push(...hidden);
|
|
215
|
+
}
|
|
185
216
|
params.push(limit, offset);
|
|
186
217
|
try {
|
|
187
218
|
const rows = this.db
|
|
@@ -211,6 +242,8 @@ export class SkillRepository {
|
|
|
211
242
|
if (!row)
|
|
212
243
|
return null;
|
|
213
244
|
const doc = rowToDoc(row);
|
|
245
|
+
if (!this.isFolderNameVisible(doc.folder))
|
|
246
|
+
return null;
|
|
214
247
|
const remote = this.remoteFor(doc.folder);
|
|
215
248
|
if (!remote || !this.credentialsBaseDir)
|
|
216
249
|
return doc;
|
package/dist/src/web/routes.js
CHANGED
|
@@ -12,6 +12,7 @@ import { attachmentsDirFor, guessMimeType } from '../attachments/storage.js';
|
|
|
12
12
|
import { listFolders as listFolderfooFolders } from '../remote/folderfoo-client.js';
|
|
13
13
|
import { setCredential } from '../remote/credentials.js';
|
|
14
14
|
import { pollOne } from '../remote/remote-sync.js';
|
|
15
|
+
import { decodeUsername } from '../remote/identity.js';
|
|
15
16
|
import { getChannel, listChannels } from '../channels/store.js';
|
|
16
17
|
function asArray(v) {
|
|
17
18
|
if (v === undefined)
|
|
@@ -272,7 +273,7 @@ function buildHealth(db) {
|
|
|
272
273
|
.map((m) => m.id);
|
|
273
274
|
return { danglingExtends, danglingRelatedTo, emptyTriggerPhrases, staleActiveMemoryDocs };
|
|
274
275
|
}
|
|
275
|
-
export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, memorySpec, remotePollers) {
|
|
276
|
+
export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, memorySpec, identity, remotePollers) {
|
|
276
277
|
const router = express.Router();
|
|
277
278
|
// Resyncs every remote source FIRST (force: true - always does real work,
|
|
278
279
|
// including reconcileDeletions, regardless of the watermark check), so a
|
|
@@ -742,8 +743,40 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
|
|
|
742
743
|
return;
|
|
743
744
|
}
|
|
744
745
|
setCredential(config.baseDir, server, token);
|
|
746
|
+
// Updates the process-wide current identity (see identity.ts) - the most
|
|
747
|
+
// recent login from ANY browser tab becomes "current" for the whole
|
|
748
|
+
// process, including MCP tool calls. Logging in doesn't itself change
|
|
749
|
+
// which entries are stamped with this identity (that only happens at
|
|
750
|
+
// connect-time, see /api/remote-folders below) - it just changes which
|
|
751
|
+
// already-stamped entries become visible.
|
|
752
|
+
identity.setUsername(decodeUsername(token));
|
|
745
753
|
res.json({ connected: true, server });
|
|
746
754
|
});
|
|
755
|
+
// Signals that the browser's folderfoo login session just ended (the page
|
|
756
|
+
// detects folderfoo's own auth-change event going to logged-out) - clears
|
|
757
|
+
// the process-wide current identity, which hides every remote folder
|
|
758
|
+
// (everywhere: web UI and MCP tool results alike) until a new login
|
|
759
|
+
// stamps a fresh identity via the route above. No server/token body: this
|
|
760
|
+
// is "the browser lost its session," not scoped to one server.
|
|
761
|
+
router.post('/api/folderfoo/logout', (_req, res) => {
|
|
762
|
+
identity.clearUsername();
|
|
763
|
+
res.json({ connected: false });
|
|
764
|
+
});
|
|
765
|
+
// Server-sent-events stream of the current identity, so an open browser
|
|
766
|
+
// tab's folder list updates the instant a login/logout happens anywhere
|
|
767
|
+
// (this tab or another one) - no polling, no page reload. Sends the
|
|
768
|
+
// current value immediately on connect, then again on every change.
|
|
769
|
+
router.get('/api/folderfoo/identity-stream', (req, res) => {
|
|
770
|
+
res.writeHead(200, {
|
|
771
|
+
'Content-Type': 'text/event-stream',
|
|
772
|
+
'Cache-Control': 'no-cache',
|
|
773
|
+
Connection: 'keep-alive',
|
|
774
|
+
});
|
|
775
|
+
const send = (current) => res.write(`data: ${JSON.stringify(current)}\n\n`);
|
|
776
|
+
send(identity.current());
|
|
777
|
+
const unsubscribe = identity.onIdentityChange(send);
|
|
778
|
+
req.on('close', unsubscribe);
|
|
779
|
+
});
|
|
747
780
|
// Lists the caller's own folderfoo folders for the "connect a folder"
|
|
748
781
|
// picker, once already logged in to `server` (via the route above).
|
|
749
782
|
router.get('/api/folderfoo/folders', async (req, res) => {
|
|
@@ -782,13 +815,21 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
|
|
|
782
815
|
res.status(400).json({ error: 'could not derive a valid folder name — provide one explicitly' });
|
|
783
816
|
return;
|
|
784
817
|
}
|
|
818
|
+
// A folder can only be connected while logged in — the identity it's
|
|
819
|
+
// stamped with (see identity.ts) is exactly the one that just made the
|
|
820
|
+
// "connect a folder" picker possible in the first place.
|
|
821
|
+
const current = identity.current();
|
|
822
|
+
if (!current.username) {
|
|
823
|
+
res.status(401).json({ error: 'not logged in to folderfoo' });
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
785
826
|
const repo = kind === 'skill' ? skillRepo : memoryRepo;
|
|
786
827
|
const spec = kind === 'skill' ? skillSpec : memorySpec;
|
|
787
|
-
const mirrorDir = mirrorDirFor(config.baseDir, folderName);
|
|
788
|
-
const remote = { name: folderName, server, tenantId, folderPath, mirrorDir };
|
|
828
|
+
const mirrorDir = mirrorDirFor(config.baseDir, current.mode, current.username, folderName);
|
|
829
|
+
const remote = { name: folderName, server, tenantId, folderPath, mirrorDir, mode: current.mode, username: current.username };
|
|
789
830
|
try {
|
|
790
831
|
repo.registerRemoteFolder(remote);
|
|
791
|
-
saveRemoteFolder(config, kind, { name: folderName, server, tenantId, folderPath });
|
|
832
|
+
saveRemoteFolder(config, kind, { name: folderName, server, tenantId, folderPath, mode: current.mode, username: current.username });
|
|
792
833
|
await pollOne(db, spec, remote, config.baseDir);
|
|
793
834
|
res.json({ name: folderName, server, tenantId, folderPath, kind });
|
|
794
835
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-memory-bucket",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MCP server exposing skill_* (reusable coding patterns) and memory_* (point-in-time working context) tools over a markdown+frontmatter source, cached into SQLite at runtime.",
|
|
6
6
|
"repository": {
|