mcp-memory-bucket 0.10.12 → 0.10.13

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.
@@ -83,9 +83,9 @@ export class SkillRepository {
83
83
  const remote = this.remoteFor(targetFolderName);
84
84
  if (!remote || !this.credentialsBaseDir)
85
85
  return;
86
- await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, targetFolderName);
86
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, targetFolderName, remote.owner);
87
87
  const skillDirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(filePath)));
88
- await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath, 'SKILL', fileContents);
88
+ await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath, 'SKILL', fileContents, remote.owner);
89
89
  }
90
90
  /**
91
91
  * Pushes one attachment's raw binary content to folderfoo, if `folderName` resolves to a remote
@@ -100,9 +100,9 @@ export class SkillRepository {
100
100
  const remote = this.remoteFor(folderName);
101
101
  if (!remote || !this.credentialsBaseDir)
102
102
  return;
103
- await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName);
103
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName, remote.owner);
104
104
  const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(attachmentFilePath)));
105
- await writeRemoteBinaryFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), data, mimeType);
105
+ await writeRemoteBinaryFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), data, mimeType, remote.owner);
106
106
  }
107
107
  /**
108
108
  * Trashes one attachment's remote copy on folderfoo, if `folderName` resolves to a remote source
@@ -115,9 +115,9 @@ export class SkillRepository {
115
115
  const remote = this.remoteFor(folderName);
116
116
  if (!remote || !this.credentialsBaseDir)
117
117
  return;
118
- await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName);
118
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName, remote.owner);
119
119
  const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(attachmentFilePath)));
120
- await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath));
120
+ await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), remote.owner);
121
121
  }
122
122
  /** Attaches the live chokidar watcher so addFolder/removeFolder can mutate it without a restart. */
123
123
  setWatcher(watcher) {
@@ -162,6 +162,10 @@ export class SkillRepository {
162
162
  * perform the initial pull itself — the caller (the web route) does one
163
163
  * immediate poll right after this returns, so content shows up without
164
164
  * waiting for the first interval tick.
165
+ *
166
+ * `remote.name` (and `remote.mirrorDir`, derived from it) must already be the FINAL, resolved
167
+ * name — see resolveAvailableName, which the web route calls first (before it derives mirrorDir)
168
+ * so a possibly-auto-suffixed name is settled before anything touches disk.
165
169
  */
166
170
  registerRemoteFolder(remote) {
167
171
  if (this.folders.some((f) => f.name === remote.name)) {
@@ -169,11 +173,40 @@ export class SkillRepository {
169
173
  }
170
174
  fs.mkdirSync(remote.mirrorDir, { recursive: true });
171
175
  this.remoteFolders.push(remote);
172
- this.addFolder({ name: remote.name, path: remote.mirrorDir });
176
+ this.addFolder({ name: remote.name, path: remote.mirrorDir }, { skipCollisionCheck: true });
173
177
  }
174
- /** Registers a new folder: appends it, scans it once, and starts watching it live. */
175
- addFolder(folder) {
176
- if (this.folders.some((f) => f.name === folder.name)) {
178
+ /**
179
+ * `name` must stay globally unique across EVERY identity this process has ever seen — see
180
+ * memory/repository.ts's identical method for the full reasoning and the own-identity-vs-
181
+ * different-identity distinction (auto-suffix vs reject). Called by the web route BEFORE deriving
182
+ * mirrorDir/calling registerRemoteFolder, so the name is fully settled before anything touches disk.
183
+ */
184
+ resolveAvailableName(requestedName, connectingUsername) {
185
+ const takenByOwnIdentity = (name) => this.folders.some((f) => f.name === name) && this.isFolderNameVisible(name);
186
+ const taken = (name) => this.folders.some((f) => f.name === name);
187
+ if (!taken(requestedName))
188
+ return requestedName;
189
+ if (takenByOwnIdentity(requestedName)) {
190
+ throw new Error(`a skill folder named "${requestedName}" already exists`);
191
+ }
192
+ let candidate = `${requestedName} (${connectingUsername})`;
193
+ let suffix = 2;
194
+ while (taken(candidate)) {
195
+ if (takenByOwnIdentity(candidate)) {
196
+ throw new Error(`a skill folder named "${candidate}" already exists`);
197
+ }
198
+ candidate = `${requestedName} (${connectingUsername}) ${suffix}`;
199
+ suffix++;
200
+ }
201
+ return candidate;
202
+ }
203
+ /**
204
+ * Registers a new folder: appends it, scans it once, and starts watching it live. A purely LOCAL
205
+ * folder has no "different identity" to auto-suffix against — any collision here is
206
+ * unconditionally a plain rejection, unlike registerRemoteFolder's own resolveAvailableName.
207
+ */
208
+ addFolder(folder, options) {
209
+ if (!options?.skipCollisionCheck && this.folders.some((f) => f.name === folder.name)) {
177
210
  throw new Error(`a skill folder named "${folder.name}" already exists`);
178
211
  }
179
212
  this.folders.push(folder);
@@ -367,7 +400,7 @@ export class SkillRepository {
367
400
  // block around this already-frontmattered blob (see memory/
368
401
  // repository.ts's get() for the confirmed real-world corruption this
369
402
  // caused).
370
- const liveBody = matter(await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath, 'SKILL')).content.trim();
403
+ const liveBody = matter(await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath, 'SKILL', remote.owner)).content.trim();
371
404
  return { ...doc, body: liveBody };
372
405
  }
373
406
  /** Fetches many skills by name in one call — e.g. hydrating full bodies for a batch of search() hits. Missing names are simply absent from the result, not errors. */
@@ -506,10 +539,10 @@ export class SkillRepository {
506
539
  await writeRemoteThenLocal(async () => {
507
540
  if (!remote || !this.credentialsBaseDir)
508
541
  return;
509
- await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder);
542
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder, remote.owner);
510
543
  const oldDirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, oldDir));
511
544
  const newDirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, newDir));
512
- await renameRemoteFolder(remote.server, this.credentialsBaseDir, remote.tenantId, oldDirRelPath, newDirRelPath);
545
+ await renameRemoteFolder(remote.server, this.credentialsBaseDir, remote.tenantId, oldDirRelPath, newDirRelPath, remote.owner);
513
546
  }, () => {
514
547
  fs.renameSync(oldDir, newDir);
515
548
  // The rename above already moved SKILL.md verbatim (old name/frontmatter) along with the
@@ -618,9 +651,9 @@ export class SkillRepository {
618
651
  const skillDir = path.dirname(existing.source_path);
619
652
  const remote = this.remoteFor(existing.folder);
620
653
  if (remote && this.credentialsBaseDir) {
621
- await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder);
654
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder, remote.owner);
622
655
  const skillDirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, skillDir));
623
- await trashRemoteFolder(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath);
656
+ await trashRemoteFolder(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath, remote.owner);
624
657
  }
625
658
  fs.rmSync(skillDir, { recursive: true, force: true });
626
659
  removeFile(this.db, 'skills', existing.source_path);
@@ -5,7 +5,7 @@ import express from 'express';
5
5
  import matter from 'gray-matter';
6
6
  import { saveFolder, saveRemoteFolder, removeFolder as removeFolderFromConfig, updateRemoteFolderPath as updateRemoteFolderPathInConfig, sanitizeFolderName, mirrorDirFor, } from '../config.js';
7
7
  import { stripKey } from '../memory/repository.js';
8
- import { initialScan } from '../store/sync.js';
8
+ import { initialScan, walkMarkdownFiles } from '../store/sync.js';
9
9
  import { sanitizeFtsQuery } from '../store/search.js';
10
10
  import { resolveWithinBase } from '../store/safe-path.js';
11
11
  import { attachmentsDirFor, guessMimeType, ATTACHMENT_MAX_BYTES } from '../attachments/storage.js';
@@ -14,7 +14,7 @@ import { setCredential } from '../remote/credentials.js';
14
14
  import { pollOne } from '../remote/remote-sync.js';
15
15
  import { decodeUsername, isFolderVisible } from '../remote/identity.js';
16
16
  import { refreshSharedItems, listSharedItems, dismissRevokedSharedItem, addSharedItem, getSharedItem, resolveShareTarget, } from '../remote/shared-items.js';
17
- import { shareWithUser, unshareWithUser, createShareLink, createPublicLink, joinRemoteFolderPath } from '../remote/folderfoo-client.js';
17
+ import { shareWithUser, unshareWithUser, createShareLink, createPublicLink, joinRemoteFolderPath, getSharedWithMe, FolderfooRequestError } from '../remote/folderfoo-client.js';
18
18
  import { readMarkdownFile } from '../store/markdown-file.js';
19
19
  import { getChannel, listChannels } from '../channels/store.js';
20
20
  function asArray(v) {
@@ -952,59 +952,109 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
952
952
  * opposed to whether its contents changed.
953
953
  *
954
954
  * Reuses folderfoo's existing GET /folders (via listFolderfooFolders) — one bulk call per
955
- * distinct folderfoo server returns every currently-existing folder path for that login, cheaper
956
- * than a per-folder existence check would be for an account with many connected sources. Sources
957
- * sharing one server (the common case — see credentials.ts, one login per server) dedupe to a
958
- * single call. A confirmed-gone folder is auto-removed via the same removeFolder()+
959
- * removeFolderFromConfig() pair the manual "✕ remove folder" route above already uses — this
960
- * never touches a folder's contents, only mem-bucket's own registration of it (see
961
- * removeFolder's doc comment: it drops DB rows + the local mirror dir, never anything the
962
- * folder's write path would consider "the user's own content" beyond that mirror).
955
+ * distinct folderfoo server returns every currently-existing folder path for the caller's OWN
956
+ * tree, cheaper than a per-folder existence check would be for an account with many connected own
957
+ * sources; these dedupe to one call per server (the common case — see credentials.ts, one login
958
+ * per server). A source connected to a folder someone ELSE shared (RemoteFolder.owner set) is a
959
+ * different case entirely — GET /folders without an owner never includes a shared folder at all,
960
+ * so each of those is checked individually via owner+rootFolder instead (see the loop below). A
961
+ * confirmed-gone folder is auto-removed via the same removeFolder()+removeFolderFromConfig() pair
962
+ * the manual "✕ remove folder" route above already uses — this never touches a folder's contents,
963
+ * only mem-bucket's own registration of it (see removeFolder's doc comment: it drops DB rows + the
964
+ * local mirror dir, never anything the folder's write path would consider "the user's own content"
965
+ * beyond that mirror).
963
966
  *
964
967
  * Deliberately does NOT run at write time: a write against a since-deleted remote folder instead
965
968
  * fails loudly with its own specific error (see repository create()/update()'s remote-push
966
969
  * handling) rather than silently recreating the folder — auto-recreating on write would silently
967
970
  * undo a deletion the user may have made deliberately, with no visibility that it happened.
971
+ *
972
+ * Only ever checks/prunes sources connected under the CURRENTLY LOGGED-IN identity (see the
973
+ * isFolderVisible guard in the loop below) — credentials.ts stores exactly one JWT per server, so
974
+ * a different identity's source checked against the wrong credential would always look
975
+ * "confirmed gone" and get silently deleted the moment ANY other identity's login triggered a
976
+ * /api/folders load. This was a real, confirmed bug: switching from user A to user B made B's own
977
+ * previously-connected folders vanish, because A's login (or nobody's) was the only credential on
978
+ * hand to check them with.
968
979
  */
969
980
  async function pruneDeletedRemoteFolders() {
970
- const byServer = new Map();
981
+ const ownFolderSources = [];
982
+ const sharedFolderSources = [];
983
+ const currentIdentity = identity.current();
971
984
  for (const [kind, repo] of [
972
985
  ['skill', skillRepo],
973
986
  ['memory', memoryRepo],
974
987
  ]) {
975
988
  for (const remote of repo.listRemoteFolders()) {
976
- const list = byServer.get(remote.server) ?? [];
977
- list.push({ kind, name: remote.name, folderPath: remote.folderPath, tenantId: remote.tenantId });
978
- byServer.set(remote.server, list);
989
+ // Only ever check a source connected under the IDENTITY CURRENTLY LOGGED IN — credentials.ts
990
+ // stores exactly one JWT per server, overwritten on every login, so a source belonging to a
991
+ // different identity (e.g. bbb's own folder, checked right after anatoli logs in) would be
992
+ // checked with the WRONG credential: that credential's owner's tree obviously never contains
993
+ // the other identity's folder, so it would always look "confirmed gone" and get silently
994
+ // deleted the moment ANY other identity's login triggered a /api/folders load — exactly the
995
+ // "my folder disappeared after switching users" bug this skip fixes. A source that doesn't
996
+ // match is left untouched here entirely; it re-enters this check once its own identity logs
997
+ // back in and can be verified with the right credential again.
998
+ if (!isFolderVisible(remote, currentIdentity))
999
+ continue;
1000
+ if (remote.owner) {
1001
+ sharedFolderSources.push({ kind, name: remote.name, folderPath: remote.folderPath, server: remote.server, tenantId: remote.tenantId, owner: remote.owner });
1002
+ }
1003
+ else {
1004
+ ownFolderSources.push({ kind, name: remote.name, folderPath: remote.folderPath, server: remote.server, tenantId: remote.tenantId });
1005
+ }
979
1006
  }
980
1007
  }
981
- if (byServer.size === 0)
982
- return;
983
- for (const [server, sources] of byServer) {
984
- let livePaths;
1008
+ const doRemove = (source) => {
1009
+ const repo = source.kind === 'skill' ? skillRepo : memoryRepo;
985
1010
  try {
986
- // Every source on one server shares one login/tenant (credentials.ts is keyed by server
987
- // alone), so any one source's tenantId is representative for the whole group's GET /folders
988
- // call.
989
- const folders = await listFolderfooFolders(server, config.baseDir, sources[0].tenantId);
990
- livePaths = new Set(folders.map((f) => f.path));
1011
+ repo.removeFolder(source.name);
1012
+ removeFolderFromConfig(config, source.kind, source.name);
991
1013
  }
992
- catch {
993
- // Not logged in, network blip, etc. — leave every source on this server untouched rather
994
- // than treating "couldn't check" as "confirmed gone."
995
- continue;
1014
+ catch (err) {
1015
+ console.error(`[memory-bucket] failed to auto-remove deleted remote folder "${source.name}":`, err);
996
1016
  }
997
- for (const source of sources) {
998
- if (livePaths.has(source.folderPath))
999
- continue;
1000
- const repo = source.kind === 'skill' ? skillRepo : memoryRepo;
1017
+ };
1018
+ // Own-folder sources: unchanged from before — GET /folders without an owner returns the
1019
+ // caller's own flat tree in one call per distinct server (sources sharing one server share one
1020
+ // login/tenant, so any one source's tenantId is representative for the whole group).
1021
+ if (ownFolderSources.length > 0) {
1022
+ const byServer = new Map();
1023
+ for (const source of ownFolderSources) {
1024
+ const list = byServer.get(source.server) ?? [];
1025
+ list.push(source);
1026
+ byServer.set(source.server, list);
1027
+ }
1028
+ for (const [server, sources] of byServer) {
1029
+ let livePaths;
1001
1030
  try {
1002
- repo.removeFolder(source.name);
1003
- removeFolderFromConfig(config, source.kind, source.name);
1031
+ const folders = await listFolderfooFolders(server, config.baseDir, sources[0].tenantId);
1032
+ livePaths = new Set(folders.map((f) => f.path));
1004
1033
  }
1005
- catch (err) {
1006
- console.error(`[memory-bucket] failed to auto-remove deleted remote folder "${source.name}":`, err);
1034
+ catch {
1035
+ continue; // not logged in, network blip, etc. — "couldn't check" is not "confirmed gone"
1007
1036
  }
1037
+ for (const source of sources) {
1038
+ if (!livePaths.has(source.folderPath))
1039
+ doRemove(source);
1040
+ }
1041
+ }
1042
+ }
1043
+ // Shared-folder sources: GET /folders without an owner never includes them at all (a shared
1044
+ // folder isn't part of the caller's own tree — see listFolders' doc comment), so the own-folder
1045
+ // check above would wrongly treat every one of these as "confirmed gone" on every single
1046
+ // /api/folders load if it ran against them. Each is checked individually instead, via
1047
+ // owner+rootFolder=its own folderPath — this is exactly what the share grant is for, so success
1048
+ // confirms both "the folder still exists" AND "the share still stands"; a 403 (share revoked) or
1049
+ // 404 (folder deleted) confirms gone, while any OTHER failure (not logged in, network blip)
1050
+ // again means "couldn't check," not "confirmed gone."
1051
+ for (const source of sharedFolderSources) {
1052
+ try {
1053
+ await listFolderfooFolders(source.server, config.baseDir, source.tenantId, { owner: source.owner, rootFolder: source.folderPath });
1054
+ }
1055
+ catch (err) {
1056
+ if (err instanceof FolderfooRequestError && (err.status === 403 || err.status === 404))
1057
+ doRemove(source);
1008
1058
  }
1009
1059
  }
1010
1060
  }
@@ -1160,23 +1210,49 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
1160
1210
  const unsubscribe = identity.onIdentityChange(send);
1161
1211
  req.on('close', unsubscribe);
1162
1212
  });
1163
- // Lists the caller's own folderfoo folders for the "connect a folder"
1164
- // picker, once already logged in to `server` (via the route above).
1213
+ // Lists folderfoo folders for the "connect a folder" picker, once already logged in to `server`
1214
+ // (via the route above). Own folders by default; pass `owner`+`rootFolder` (a folder from
1215
+ // GET /api/folderfoo/shared-folders below) to browse INTO a folder someone else shared instead —
1216
+ // folderfoo's own GET /folders gates that via the same hasAccess check every other shared read
1217
+ // uses, so this route just forwards the params through rather than re-checking anything itself.
1165
1218
  router.get('/api/folderfoo/folders', async (req, res) => {
1166
1219
  const server = req.query.server;
1167
1220
  const tenantId = req.query.tenantId;
1221
+ const owner = req.query.owner;
1222
+ const rootFolder = req.query.rootFolder;
1168
1223
  if (!server || !tenantId) {
1169
1224
  res.status(400).json({ error: 'server and tenantId query params are required' });
1170
1225
  return;
1171
1226
  }
1172
1227
  try {
1173
- const folders = await listFolderfooFolders(server, config.baseDir, tenantId);
1228
+ const folders = await listFolderfooFolders(server, config.baseDir, tenantId, { owner, rootFolder });
1174
1229
  res.json(folders);
1175
1230
  }
1176
1231
  catch (err) {
1177
1232
  res.status(401).json({ error: err.message });
1178
1233
  }
1179
1234
  });
1235
+ // Lists whole FOLDERS (not individual memory docs/skills — see /api/shared-items above for that)
1236
+ // someone has shared directly with the caller, across every owner — backs the "connect a folder"
1237
+ // picker's "Shared with me" source, so a folder like bbbmemz shows up as something to connect
1238
+ // instead of only being visible via folderfoo's own File Open UI. Filters getSharedWithMe's
1239
+ // combined file+folder list down to type:'folder' — a shared FILE has no meaning here, that's the
1240
+ // item-level sharing feature instead.
1241
+ router.get('/api/folderfoo/shared-folders', async (req, res) => {
1242
+ const server = req.query.server;
1243
+ const tenantId = req.query.tenantId;
1244
+ if (!server || !tenantId) {
1245
+ res.status(400).json({ error: 'server and tenantId query params are required' });
1246
+ return;
1247
+ }
1248
+ try {
1249
+ const shared = await getSharedWithMe(server, config.baseDir, tenantId);
1250
+ res.json(shared.filter((entry) => entry.type === 'folder').map((entry) => ({ owner: entry.owner, path: entry.name, role: entry.role })));
1251
+ }
1252
+ catch (err) {
1253
+ res.status(401).json({ error: err.message });
1254
+ }
1255
+ });
1180
1256
  // Registers a REMOTE (folderfoo) source — the connect-a-folder counterpart
1181
1257
  // to POST /api/folders above, which only ever handles local filesystem
1182
1258
  // paths. Creates the local mirror directory, registers it with the
@@ -1184,7 +1260,7 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
1184
1260
  // immediately triggers one poll so content shows up without waiting for
1185
1261
  // the first interval tick.
1186
1262
  router.post('/api/remote-folders', async (req, res) => {
1187
- const { kind, name, server, tenantId, folderPath } = req.body;
1263
+ const { kind, name, server, tenantId, folderPath, owner } = req.body;
1188
1264
  if (kind !== 'skill' && kind !== 'memory') {
1189
1265
  res.status(400).json({ error: 'kind must be "skill" or "memory"' });
1190
1266
  return;
@@ -1208,18 +1284,53 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
1208
1284
  }
1209
1285
  const repo = kind === 'skill' ? skillRepo : memoryRepo;
1210
1286
  const spec = kind === 'skill' ? skillSpec : memorySpec;
1211
- const mirrorDir = mirrorDirFor(config.baseDir, current.mode, current.username, folderName);
1212
- const remote = { name: folderName, server, tenantId, folderPath, mirrorDir, mode: current.mode, username: current.username };
1213
1287
  try {
1288
+ // Resolved FIRST (before deriving mirrorDir) — auto-suffixes the requested name when it
1289
+ // collides with a folder connected under a DIFFERENT folderfoo login (e.g. two different users
1290
+ // each naturally wanting "bbbmemz"), still rejecting a collision against the CALLER's own
1291
+ // identity (a genuine naming mistake) — see resolveAvailableName's own doc comment. Everything
1292
+ // downstream (mirrorDir, the saved config entry, the response, the kind-mismatch check) uses
1293
+ // this resolved name, never the originally-requested one, so nothing reads/writes to a path
1294
+ // computed from a name that ended up not being what actually got connected.
1295
+ const resolvedName = repo.resolveAvailableName(folderName, current.username);
1296
+ const mirrorDir = mirrorDirFor(config.baseDir, current.mode, current.username, resolvedName, owner);
1297
+ const remote = { name: resolvedName, server, tenantId, folderPath, mirrorDir, mode: current.mode, username: current.username, owner };
1214
1298
  repo.registerRemoteFolder(remote);
1215
- saveRemoteFolder(config, kind, { name: folderName, server, tenantId, folderPath, mode: current.mode, username: current.username });
1299
+ saveRemoteFolder(config, kind, { name: resolvedName, server, tenantId, folderPath, mode: current.mode, username: current.username, owner });
1216
1300
  await pollOne(db, spec, remote, config.baseDir);
1217
- res.json({ name: folderName, server, tenantId, folderPath, kind });
1301
+ res.json({ name: resolvedName, server, tenantId, folderPath, kind, owner, kindMismatchWarning: detectKindMismatch(kind, mirrorDir) });
1218
1302
  }
1219
1303
  catch (err) {
1220
1304
  res.status(409).json({ error: err.message });
1221
1305
  }
1222
1306
  });
1307
+ // Cheap best-effort "did the user pick the wrong kind" heuristic, checked once right after connect
1308
+ // (content is already mirrored locally from the pollOne call above, so this is a local directory
1309
+ // walk, not another network round trip) — surfaced to the client as a dismissible warning, never a
1310
+ // hard block, since a folder can legitimately be empty or genuinely mixed. A skill's remote content
1311
+ // always lives under a fixed literal filename "SKILL.md" (see remoteFilename's own doc comment in
1312
+ // sync.ts — skills push under that name regardless of local filename), so "every .md file mirrored
1313
+ // is literally named SKILL.md" is a strong, cheap skill-shaped signal; a memory folder's files keep
1314
+ // their own varied names and are never named exactly that. Returns undefined (no warning) for an
1315
+ // empty folder — nothing synced yet is not evidence of anything.
1316
+ function detectKindMismatch(kind, mirrorDir) {
1317
+ let total = 0;
1318
+ let skillNamed = 0;
1319
+ for (const file of walkMarkdownFiles(mirrorDir)) {
1320
+ total++;
1321
+ if (path.basename(file) === 'SKILL.md')
1322
+ skillNamed++;
1323
+ }
1324
+ if (total === 0)
1325
+ return undefined;
1326
+ if (kind === 'memory' && skillNamed === total) {
1327
+ return `This folder looks like it contains skills (every file is a SKILL.md), not memory docs — you connected it as a memory folder. Remove and reconnect it as a skill folder instead.`;
1328
+ }
1329
+ if (kind === 'skill' && skillNamed === 0) {
1330
+ return `This folder looks like it contains memory docs, not skills (no SKILL.md files found) — you connected it as a skill folder. Remove and reconnect it as a memory folder instead.`;
1331
+ }
1332
+ return undefined;
1333
+ }
1223
1334
  // Triggers one immediate out-of-cycle poll for a single remote source —
1224
1335
  // backs the web UI's per-source "resync now" action, so a user who just
1225
1336
  // saved something remotely doesn't have to wait out the fixed poll
@@ -1349,11 +1460,19 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
1349
1460
  res.status(500).json({ error: err.message });
1350
1461
  }
1351
1462
  });
1352
- // The ONLY way shared_items ever updates after a share is first accepted —
1353
- // the recycle-icon button in the "Shared with me" panel calls this.
1463
+ // The recycle-icon button in the "Shared with me" panel calls this — also the only way a direct
1464
+ // (non-link) share ever gets discovered and added in the first place, since folderfoo's Share
1465
+ // Manager UI and bucket_share_item grant access with no redeem event for this app to react to
1466
+ // (see refreshSharedItems' own doc comment). Polls every server+tenant the caller is connected to
1467
+ // via a remote folder, not just ones already represented in shared_items, so this works even
1468
+ // before the very first share has been accepted.
1354
1469
  router.post('/api/shared-items/refresh', async (_req, res) => {
1355
1470
  try {
1356
- const summary = await refreshSharedItems(db, config.baseDir, skillSpec, memorySpec);
1471
+ const remoteFolders = [...skillRepo.listRemoteFolders(), ...memoryRepo.listRemoteFolders()].map((f) => ({
1472
+ server: f.server,
1473
+ tenantId: f.tenantId,
1474
+ }));
1475
+ const summary = await refreshSharedItems(db, config.baseDir, skillSpec, memorySpec, remoteFolders);
1357
1476
  res.json(summary);
1358
1477
  }
1359
1478
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-memory-bucket",
3
- "version": "0.10.12",
3
+ "version": "0.10.13",
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": {