file-share-mcp 1.0.14 → 1.0.16

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
@@ -11,6 +11,11 @@ The agent can list, read, and notify about files. The user can upload, download,
11
11
  - `list_shared_files` — List all files available in the shared folder
12
12
  - `share_file` — Notify the user that a file is ready to download
13
13
  - `preview_file` — Read the content of a `.txt` or `.md` file (max 1 MB)
14
+ - `write_file` — Write text content to a new file in the shared folder
15
+ - `copy_file` — Copy a file from the agent workspace into the shared folder
16
+ - `download_file` — Copy a file from the shared folder to the agent machine (any file type, binary included)
17
+ - `create_directory` / `delete_directory` — Manage subdirectories
18
+ - `move_file` — Move or rename a file inside the shared folder
14
19
  - **Web GUI** — Upload, download, preview, and delete files from a browser
15
20
  - **Zero config** — works out of the box with sensible defaults (`~/.file-share-mcp`)
16
21
  - **Proxy-ready** — optional integration with the [file-share-mcp proxy](https://github.com/...)
@@ -112,11 +117,21 @@ By default, all user data is stored in `~/.file-share-mcp/`:
112
117
  ~/.file-share-mcp/
113
118
  ├── configs.json # Saved configuration (shared folder path, etc.)
114
119
  ├── shared/ # Default shared folder
120
+ │ └── .uploads-tmp/ # Staging area for in-flight GUI uploads (hidden, purged at startup)
115
121
  └── logs/
116
122
  └── server.log # Application logs
117
123
  ```
118
124
 
119
- This directory survives package updates and reinstalls.
125
+ This directory survives package updates and reinstalls. Both processes (MCP server
126
+ and GUI) log under `<data dir>/logs`, never under the working directory — the cwd of
127
+ an MCP launched by a gateway is not its own and is not necessarily writable.
128
+
129
+ Uploads are staged in `.uploads-tmp` inside the shared folder rather than in the
130
+ system temp directory, so the final move never crosses a mount boundary. `rename()`
131
+ returns `EXDEV` as soon as it does, even when both sides are on the same filesystem —
132
+ which is the case under systemd `PrivateTmp=yes`, with `/tmp` on tmpfs, or with a
133
+ `TMPDIR` on another volume. A copy-then-rename fallback covers the remaining case
134
+ where `MCP_DATA_DIR` and a custom `shared_folder_path` sit on different volumes.
120
135
 
121
136
  ---
122
137
 
package/dist/gui.js CHANGED
@@ -4,18 +4,52 @@ import cors from 'cors';
4
4
  import path from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import fs from 'fs';
7
- import os from 'os';
8
7
  import { setupLogging, createLogger, ProxyClient } from '@imenam/mcp-gui-interface';
9
8
  import { getConfig, saveConfig } from './utils/config-manager.js';
10
- import { getSharedFiles, validateSafePath, deleteFile, createDirectory, moveFile, deleteDirectory } from './utils/file-manager.js';
9
+ import { getSharedFiles, validateSafePath, deleteFile, createDirectory, moveFile, deleteDirectory, movePath, UPLOAD_TMP_DIRNAME } from './utils/file-manager.js';
11
10
  const __filename = fileURLToPath(import.meta.url);
12
11
  const __dirname = path.dirname(__filename);
13
- setupLogging({ processLabel: 'GUI', logDir: path.join(os.homedir(), '.file-share-mcp', 'logs') });
12
+ setupLogging({ processLabel: 'GUI', logDir: path.join(getConfig().MCP_DATA_DIR, 'logs') });
14
13
  const logger = createLogger('GUI');
15
14
  const app = express();
16
15
  app.use(cors());
17
16
  app.use(express.json());
18
- const upload = multer({ dest: os.tmpdir() });
17
+ // Stage uploads inside the shared folder: the destination always lives there, so
18
+ // the final rename stays within one mount. os.tmpdir() cannot promise that — under
19
+ // systemd PrivateTmp, /tmp is a separate mount and rename() returns EXDEV.
20
+ function getUploadTmpDir() {
21
+ const { shared_folder_path } = getConfig();
22
+ const dir = path.join(path.resolve(shared_folder_path), UPLOAD_TMP_DIRNAME);
23
+ fs.mkdirSync(dir, { recursive: true });
24
+ return dir;
25
+ }
26
+ function purgeUploadTmpDir() {
27
+ try {
28
+ const dir = getUploadTmpDir();
29
+ for (const name of fs.readdirSync(dir)) {
30
+ try {
31
+ fs.unlinkSync(path.join(dir, name));
32
+ }
33
+ catch { }
34
+ }
35
+ }
36
+ catch (err) {
37
+ logger.warn(`Could not purge upload staging dir: ${err.message}`);
38
+ }
39
+ }
40
+ purgeUploadTmpDir();
41
+ const upload = multer({
42
+ storage: multer.diskStorage({
43
+ destination: (_req, _file, cb) => {
44
+ try {
45
+ cb(null, getUploadTmpDir());
46
+ }
47
+ catch (err) {
48
+ cb(err, '');
49
+ }
50
+ },
51
+ }),
52
+ });
19
53
  // Endpoints
20
54
  app.get('/proxy/health', (req, res) => {
21
55
  res.json({ status: 'healthy' });
@@ -104,7 +138,7 @@ app.post('/api/upload', upload.single('file'), (req, res) => {
104
138
  const dir = validateSafePath(targetPath);
105
139
  fs.mkdirSync(dir, { recursive: true });
106
140
  const dest = path.join(dir, path.basename(req.file.originalname));
107
- fs.renameSync(req.file.path, dest);
141
+ movePath(req.file.path, dest);
108
142
  res.json({ message: 'File uploaded successfully', filename: path.basename(req.file.originalname) });
109
143
  }
110
144
  catch (err) {
@@ -8,11 +8,13 @@ import { fileURLToPath } from 'url';
8
8
  import fs from 'fs';
9
9
  import { GuiLauncher, setupLogging, createLogger, reconnectGuiToolDefinition } from '@imenam/mcp-gui-interface';
10
10
  import { getConfig } from './utils/config-manager.js';
11
- import { getSharedFiles, validateSafePath, writeSharedFile, copyToSharedFolder, createDirectory, deleteDirectory, moveFile, } from './utils/file-manager.js';
11
+ import { getSharedFiles, validateSafePath, writeSharedFile, copyToSharedFolder, copyFromSharedFolder, createDirectory, deleteDirectory, moveFile, } from './utils/file-manager.js';
12
12
  import { VERSION } from './version.js';
13
13
  const __filename = fileURLToPath(import.meta.url);
14
14
  const __dirname = path.dirname(__filename);
15
- setupLogging({ processLabel: 'MASTER' });
15
+ // Always log under MCP_DATA_DIR: the cwd of an MCP launched by a gateway is not
16
+ // ours and is not necessarily writable (the library default is <cwd>/.mcp-gui/logs).
17
+ setupLogging({ processLabel: 'MASTER', logDir: path.join(getConfig().MCP_DATA_DIR, 'logs') });
16
18
  const logger = createLogger('MASTER');
17
19
  const server = new Server({
18
20
  name: 'file-share-mcp',
@@ -133,6 +135,28 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
133
135
  required: ['source_path'],
134
136
  },
135
137
  },
138
+ {
139
+ name: 'download_file',
140
+ description: 'Download a file from the shared folder to the agent workspace (the reverse of copy_file). Works for any file type, including binary ones.',
141
+ inputSchema: {
142
+ type: 'object',
143
+ properties: {
144
+ path: {
145
+ type: 'string',
146
+ description: 'Relative path of the file in the shared folder (e.g. "exports/report.pdf").',
147
+ },
148
+ destination_path: {
149
+ type: 'string',
150
+ description: 'ABSOLUTE destination path on the agent machine: either an existing directory (the file keeps its name) or the full target file path. Missing parent directories are created.',
151
+ },
152
+ overwrite: {
153
+ type: 'boolean',
154
+ description: 'Replace the destination file if it already exists. Defaults to false.',
155
+ },
156
+ },
157
+ required: ['path', 'destination_path'],
158
+ },
159
+ },
136
160
  {
137
161
  name: 'create_directory',
138
162
  description: 'Create a new subdirectory inside the shared folder.',
@@ -266,6 +290,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
266
290
  content: [{ type: 'text', text: `File "${copiedName}" copied to shared folder.` }],
267
291
  };
268
292
  }
293
+ case 'download_file': {
294
+ const { path: relativePath, destination_path, overwrite } = z.object({
295
+ path: z.string(),
296
+ destination_path: z.string(),
297
+ overwrite: z.boolean().optional(),
298
+ }).parse(args);
299
+ const written = copyFromSharedFolder(relativePath, destination_path, overwrite ?? false);
300
+ return {
301
+ content: [{ type: 'text', text: `File "${relativePath}" downloaded to "${written}".` }],
302
+ };
303
+ }
269
304
  case 'create_directory': {
270
305
  const { path: dirPath } = z.object({ path: z.string() }).parse(args);
271
306
  createDirectory(dirPath);
@@ -1,6 +1,9 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { getConfig } from './config-manager.js';
4
+ /** Staging directory for uploads, kept inside the shared folder so the final
5
+ * rename never crosses a mount boundary. Hidden from listings. */
6
+ export const UPLOAD_TMP_DIRNAME = '.uploads-tmp';
4
7
  export function validateSafePath(relativePath) {
5
8
  const { shared_folder_path } = getConfig();
6
9
  const resolvedBase = path.resolve(shared_folder_path);
@@ -10,13 +13,52 @@ export function validateSafePath(relativePath) {
10
13
  }
11
14
  return fullPath;
12
15
  }
16
+ const PARTIAL_UPLOAD_RE = /^\..*\.part-\d+-\d+$/;
17
+ function isPartialUpload(name) {
18
+ return PARTIAL_UPLOAD_RE.test(name);
19
+ }
20
+ /**
21
+ * Move `srcPath` onto `destPath`, atomically whenever the kernel allows it.
22
+ *
23
+ * `rename()` fails with EXDEV as soon as it crosses a mount boundary, even when
24
+ * both sides live on the same filesystem — which is what happens under systemd
25
+ * `PrivateTmp=yes`, with `/tmp` on tmpfs, or with a TMPDIR on another volume.
26
+ * The fallback copies next to the destination first, then renames within that
27
+ * same directory, so readers never observe a half-written file either way.
28
+ */
29
+ export function movePath(srcPath, destPath) {
30
+ try {
31
+ fs.renameSync(srcPath, destPath);
32
+ return;
33
+ }
34
+ catch (err) {
35
+ if (err.code !== 'EXDEV')
36
+ throw err;
37
+ }
38
+ const staged = path.join(path.dirname(destPath), `.${path.basename(destPath)}.part-${process.pid}-${Date.now()}`);
39
+ try {
40
+ fs.copyFileSync(srcPath, staged);
41
+ fs.renameSync(staged, destPath);
42
+ }
43
+ catch (err) {
44
+ try {
45
+ fs.unlinkSync(staged);
46
+ }
47
+ catch { }
48
+ throw err;
49
+ }
50
+ fs.unlinkSync(srcPath);
51
+ }
13
52
  export function getSharedFiles(relativePath = '') {
14
53
  const dirPath = validateSafePath(relativePath);
15
54
  if (!fs.existsSync(dirPath)) {
16
55
  fs.mkdirSync(dirPath, { recursive: true });
17
56
  return [];
18
57
  }
19
- return fs.readdirSync(dirPath).map(name => {
58
+ const hidden = relativePath === '' ? new Set([UPLOAD_TMP_DIRNAME]) : new Set();
59
+ return fs.readdirSync(dirPath).filter(name => {
60
+ return !hidden.has(name) && !isPartialUpload(name);
61
+ }).map(name => {
20
62
  const fullPath = path.join(dirPath, name);
21
63
  const stats = fs.statSync(fullPath);
22
64
  if (stats.isDirectory()) {
@@ -59,6 +101,36 @@ export function copyToSharedFolder(sourcePath, filename, relativePath = '') {
59
101
  fs.copyFileSync(sourcePath, path.join(dir, targetFilename));
60
102
  return relativePath ? `${relativePath}/${targetFilename}` : targetFilename;
61
103
  }
104
+ /**
105
+ * Copy a file out of the shared folder onto the agent machine.
106
+ *
107
+ * `destination` is either a directory (existing, or written with a trailing
108
+ * separator) — the file then keeps its name — or the full target file path.
109
+ */
110
+ export function copyFromSharedFolder(relativePath, destination, overwrite = false) {
111
+ const sourcePath = validateSafePath(relativePath);
112
+ if (!fs.existsSync(sourcePath)) {
113
+ throw new Error(`File not found in shared folder: ${relativePath}`);
114
+ }
115
+ if (!fs.statSync(sourcePath).isFile()) {
116
+ throw new Error(`Path is not a file: ${relativePath}`);
117
+ }
118
+ if (!path.isAbsolute(destination)) {
119
+ throw new Error(`Destination must be an absolute path: ${destination}`);
120
+ }
121
+ const looksLikeDir = destination.endsWith('/') ||
122
+ destination.endsWith(path.sep) ||
123
+ (fs.existsSync(destination) && fs.statSync(destination).isDirectory());
124
+ const destPath = looksLikeDir
125
+ ? path.join(destination, path.basename(sourcePath))
126
+ : destination;
127
+ if (!overwrite && fs.existsSync(destPath)) {
128
+ throw new Error(`Destination already exists: ${destPath}. Pass overwrite=true to replace it.`);
129
+ }
130
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
131
+ fs.copyFileSync(sourcePath, destPath);
132
+ return destPath;
133
+ }
62
134
  export function createDirectory(relativePath) {
63
135
  const fullPath = validateSafePath(relativePath);
64
136
  fs.mkdirSync(fullPath, { recursive: true });
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '1.0.14';
1
+ export const VERSION = '1.0.16';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "file-share-mcp",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "MCP server for bidirectional file sharing between AI agents and users, with a built-in web GUI",
5
5
  "author": "",
6
6
  "license": "ISC",