gcs-google-mcp-server 0.1.11 → 0.1.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.
- package/README.md +71 -14
- package/package.json +2 -2
- package/shared/gcs-client/gcs-client.d.ts +2 -0
- package/shared/gcs-client/gcs-client.integration-mock.d.ts +1 -1
- package/shared/gcs-client/gcs-client.integration-mock.js +29 -4
- package/shared/gcs-client/gcs-client.js +23 -0
- package/shared/tools/put-object-from-path.d.ts +69 -0
- package/shared/tools/put-object-from-path.js +92 -0
- package/shared/tools/upload-prefix.d.ts +68 -0
- package/shared/tools/upload-prefix.js +172 -0
- package/shared/tools.js +4 -0
package/README.md
CHANGED
|
@@ -20,19 +20,21 @@ MCP server for Google Cloud Storage operations with fine-grained tool access con
|
|
|
20
20
|
|
|
21
21
|
### Tools
|
|
22
22
|
|
|
23
|
-
| Tool
|
|
24
|
-
|
|
|
25
|
-
| `list_buckets`
|
|
26
|
-
| `list_objects`
|
|
27
|
-
| `get_object`
|
|
28
|
-
| `download_object`
|
|
29
|
-
| `download_prefix`
|
|
30
|
-
| `head_bucket`
|
|
31
|
-
| `put_object`
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
23
|
+
| Tool | Group | Description |
|
|
24
|
+
| ---------------------- | --------- | ------------------------------------------------------------------------------- |
|
|
25
|
+
| `list_buckets` | readonly | List all GCS buckets in the Google Cloud project |
|
|
26
|
+
| `list_objects` | readonly | List objects in a bucket with prefix and pagination |
|
|
27
|
+
| `get_object` | readonly | Get object contents as text |
|
|
28
|
+
| `download_object` | readonly | Download a single object to a local file (binary-safe) |
|
|
29
|
+
| `download_prefix` | readonly | Recursively download a prefix to a local directory (binary-safe) |
|
|
30
|
+
| `head_bucket` | readonly | Check if a bucket exists and is accessible |
|
|
31
|
+
| `put_object` | readwrite | Upload or update an object from inline string content |
|
|
32
|
+
| `put_object_from_path` | readwrite | Upload a single local file by streaming from disk (binary-safe, context-free) |
|
|
33
|
+
| `upload_prefix` | readwrite | Recursively upload a local directory tree from disk (binary-safe, context-free) |
|
|
34
|
+
| `copy_object` | readwrite | Copy an object within or across buckets |
|
|
35
|
+
| `create_bucket` | readwrite | Create a new GCS bucket |
|
|
36
|
+
| `delete_object` | delete | Delete an object from a bucket |
|
|
37
|
+
| `delete_bucket` | delete | Delete an empty GCS bucket |
|
|
36
38
|
|
|
37
39
|
### Downloading to Local Disk
|
|
38
40
|
|
|
@@ -56,6 +58,30 @@ MCP server for Google Cloud Storage operations with fine-grained tool access con
|
|
|
56
58
|
|
|
57
59
|
The inline `files` list is capped (`maxInlineEntries`, default 100), but `objectCount` and `totalBytes` always reflect the full download. When `destinationDir` is omitted it defaults to a unique folder under the OS temp directory.
|
|
58
60
|
|
|
61
|
+
### Uploading from Local Disk
|
|
62
|
+
|
|
63
|
+
`put_object` takes the object content inline as a string argument, which routes every byte through the model context and requires base64 for binary data — impractical for large files or bulk uploads. For uploading files or folders **without consuming context**, use the local-path upload tools instead. Both stream **raw bytes** directly from disk to GCS server-side, returning only metadata. They belong to the `readwrite` toolgroup, so read-only deployments never gain them.
|
|
64
|
+
|
|
65
|
+
- **`put_object_from_path`** — upload a single local file by streaming it from a filesystem path. Content type is auto-detected from the file extension (override with `contentType`). Binary files (images, archives) work without base64. Returns `{ bucket, key, sourcePath, size, etag, generation }`.
|
|
66
|
+
- **`upload_prefix`** — recursively upload every file under a local directory, preserving the directory structure as key paths beneath a destination prefix. Symbolic links to files are followed and uploaded (links to directories are skipped to avoid cycles), per-file failures are collected without aborting the batch, and it returns a manifest:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"bucket": "my-bucket",
|
|
71
|
+
"sourceDir": "/tmp/exports",
|
|
72
|
+
"destPrefix": "uploads/",
|
|
73
|
+
"objectCount": 1234,
|
|
74
|
+
"totalBytes": 5678901,
|
|
75
|
+
"files": [
|
|
76
|
+
{ "localPath": "/tmp/exports/01/data.json", "key": "uploads/01/data.json", "size": 1234 }
|
|
77
|
+
],
|
|
78
|
+
"filesTruncated": true,
|
|
79
|
+
"errors": []
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The inline `files` list is capped (`maxInlineEntries`, default 100), but `objectCount` and `totalBytes` always reflect the full upload. When `destPrefix` is omitted, files upload to the bucket root.
|
|
84
|
+
|
|
59
85
|
### Resources
|
|
60
86
|
|
|
61
87
|
| Resource | Description |
|
|
@@ -101,7 +127,38 @@ When set:
|
|
|
101
127
|
- The `bucket` parameter is automatically injected and hidden from tool inputs
|
|
102
128
|
- For `copy_object`, both source and destination are constrained to the specified bucket
|
|
103
129
|
|
|
104
|
-
This
|
|
130
|
+
This lets you grant a least-privilege service account that has only **object-level** access to one bucket, without project-level bucket-listing permission. The startup healthcheck honors this: when `GCS_BUCKET` is set it validates credentials with an object-scoped probe (`storage.objects.list` on that bucket) and never calls the project-level bucket list or a bucket-metadata probe. See [Required IAM permissions](#required-iam-permissions) for the exact permissions per mode.
|
|
131
|
+
|
|
132
|
+
## Required IAM permissions
|
|
133
|
+
|
|
134
|
+
The minimal IAM permissions depend on whether the server is constrained to a single bucket (`GCS_BUCKET`) and which tool groups you enable. The startup healthcheck validates credentials within these bounds — it does **not** require any permission beyond what the table below lists for your mode.
|
|
135
|
+
|
|
136
|
+
### Startup healthcheck
|
|
137
|
+
|
|
138
|
+
| Mode | Permission the healthcheck needs | Notes |
|
|
139
|
+
| ------------------------------------ | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
140
|
+
| **Single bucket** (`GCS_BUCKET` set) | `storage.objects.list` on the constrained bucket | Probes `list_objects` with `maxResults: 1`. Does **not** require project-level `storage.buckets.list` or bucket-metadata `storage.buckets.get`, so a correctly least-privilege, object-only service account can start. |
|
|
141
|
+
| **Unconstrained** (no `GCS_BUCKET`) | project-level `storage.buckets.list` | Validates by listing buckets in the project. |
|
|
142
|
+
|
|
143
|
+
Set `SKIP_HEALTH_CHECKS=true` to bypass startup validation entirely (disables **all** credential checks — not recommended).
|
|
144
|
+
|
|
145
|
+
### Per-tool permissions
|
|
146
|
+
|
|
147
|
+
Beyond the healthcheck, each tool needs the corresponding GCS permission at runtime. Grant only what the tool groups you enable require:
|
|
148
|
+
|
|
149
|
+
| Tool / group | Permission |
|
|
150
|
+
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
151
|
+
| `list_objects` (readonly) | `storage.objects.list` |
|
|
152
|
+
| `get_object`, `download_object` (readonly) | `storage.objects.get` |
|
|
153
|
+
| `download_prefix` (readonly) | `storage.objects.list`, `storage.objects.get` |
|
|
154
|
+
| `list_buckets` (readonly) | `storage.buckets.list` |
|
|
155
|
+
| `head_bucket` (readonly) | `storage.buckets.get` |
|
|
156
|
+
| `put_object`, `put_object_from_path`, `upload_prefix`, `copy_object` (readwrite) | `storage.objects.create`, `storage.objects.get` (these tools read each object's metadata back after writing to return its `etag`/`generation`; `copy_object` additionally reads the copy source) |
|
|
157
|
+
| `create_bucket` (readwrite) | `storage.buckets.create` |
|
|
158
|
+
| `delete_object` (delete) | `storage.objects.delete` |
|
|
159
|
+
| `delete_bucket` (delete) | `storage.buckets.delete` |
|
|
160
|
+
|
|
161
|
+
For a read-only, single-bucket service account, the `roles/storage.objectViewer` role on the bucket (or a custom role with `storage.objects.list` + `storage.objects.get`) is sufficient — no project-level or bucket-metadata permissions are needed.
|
|
105
162
|
|
|
106
163
|
## Quick Start
|
|
107
164
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gcs-google-mcp-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "MCP server for Google Cloud Storage operations with fine-grained tool access control",
|
|
5
5
|
"mcpName": "com.pulsemcp/gcs",
|
|
6
6
|
"main": "build/index.js",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/node": "^22.10.6",
|
|
40
|
-
"tsx": "^4.
|
|
40
|
+
"tsx": "^4.22.4",
|
|
41
41
|
"typescript": "^5.7.3"
|
|
42
42
|
},
|
|
43
43
|
"keywords": [
|
|
@@ -59,6 +59,7 @@ export interface IGCSClient {
|
|
|
59
59
|
getObject(bucket: string, key: string): Promise<GetObjectResult>;
|
|
60
60
|
getObjectBytes(bucket: string, key: string): Promise<GetObjectBytesResult>;
|
|
61
61
|
putObject(bucket: string, key: string, content: string, options?: PutObjectOptions): Promise<PutObjectResult>;
|
|
62
|
+
uploadFile(bucket: string, sourcePath: string, key: string, options?: PutObjectOptions): Promise<PutObjectResult>;
|
|
62
63
|
deleteObject(bucket: string, key: string): Promise<void>;
|
|
63
64
|
createBucket(bucket: string, location?: string): Promise<void>;
|
|
64
65
|
deleteBucket(bucket: string): Promise<void>;
|
|
@@ -73,6 +74,7 @@ export declare class GoogleCloudStorageClient implements IGCSClient {
|
|
|
73
74
|
getObject(bucket: string, key: string): Promise<GetObjectResult>;
|
|
74
75
|
getObjectBytes(bucket: string, key: string): Promise<GetObjectBytesResult>;
|
|
75
76
|
putObject(bucket: string, key: string, content: string, options?: PutObjectOptions): Promise<PutObjectResult>;
|
|
77
|
+
uploadFile(bucket: string, sourcePath: string, key: string, options?: PutObjectOptions): Promise<PutObjectResult>;
|
|
76
78
|
deleteObject(bucket: string, key: string): Promise<void>;
|
|
77
79
|
createBucket(bucket: string, location?: string): Promise<void>;
|
|
78
80
|
deleteBucket(bucket: string): Promise<void>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
1
2
|
export function createIntegrationMockGCSClient(mockData = {}) {
|
|
2
3
|
// Default mock data
|
|
3
4
|
const buckets = mockData.buckets || [
|
|
@@ -40,7 +41,7 @@ export function createIntegrationMockGCSClient(mockData = {}) {
|
|
|
40
41
|
return {
|
|
41
42
|
objects: keys.map((key) => ({
|
|
42
43
|
key,
|
|
43
|
-
size: bucketObjects[key]?.content
|
|
44
|
+
size: bucketObjects[key]?.content ? Buffer.byteLength(bucketObjects[key].content) : 0,
|
|
44
45
|
lastModified: bucketObjects[key]?.lastModified || new Date(),
|
|
45
46
|
storageClass: 'STANDARD',
|
|
46
47
|
etag: '"mock-etag"',
|
|
@@ -56,10 +57,11 @@ export function createIntegrationMockGCSClient(mockData = {}) {
|
|
|
56
57
|
throw new Error(`Object not found: ${bucket}/${key}`);
|
|
57
58
|
}
|
|
58
59
|
const obj = bucketObjects[key];
|
|
60
|
+
const contentStr = Buffer.isBuffer(obj.content) ? obj.content.toString('utf-8') : obj.content;
|
|
59
61
|
return {
|
|
60
|
-
content:
|
|
62
|
+
content: contentStr,
|
|
61
63
|
contentType: obj.contentType || 'text/plain',
|
|
62
|
-
contentLength: obj.content
|
|
64
|
+
contentLength: Buffer.byteLength(obj.content),
|
|
63
65
|
lastModified: obj.lastModified || new Date(),
|
|
64
66
|
etag: '"mock-etag"',
|
|
65
67
|
metadata: obj.metadata || {},
|
|
@@ -70,7 +72,8 @@ export function createIntegrationMockGCSClient(mockData = {}) {
|
|
|
70
72
|
if (!bucketObjects || !bucketObjects[key]) {
|
|
71
73
|
throw new Error(`Object not found: ${bucket}/${key}`);
|
|
72
74
|
}
|
|
73
|
-
const
|
|
75
|
+
const stored = bucketObjects[key].content;
|
|
76
|
+
const content = Buffer.isBuffer(stored) ? stored : Buffer.from(stored);
|
|
74
77
|
return {
|
|
75
78
|
content,
|
|
76
79
|
contentType: bucketObjects[key].contentType || 'text/plain',
|
|
@@ -95,6 +98,28 @@ export function createIntegrationMockGCSClient(mockData = {}) {
|
|
|
95
98
|
generation: undefined,
|
|
96
99
|
};
|
|
97
100
|
},
|
|
101
|
+
async uploadFile(bucket, sourcePath, key, uploadOptions = {}) {
|
|
102
|
+
if (!objects) {
|
|
103
|
+
throw new Error('Mock data not initialized');
|
|
104
|
+
}
|
|
105
|
+
if (!objects[bucket]) {
|
|
106
|
+
objects[bucket] = {};
|
|
107
|
+
}
|
|
108
|
+
// Read the raw bytes from disk so the upload path is exercised end-to-end
|
|
109
|
+
// and binary content is preserved faithfully (mirrors the real client,
|
|
110
|
+
// which streams bytes directly from disk without any text decoding).
|
|
111
|
+
const content = await readFile(sourcePath);
|
|
112
|
+
objects[bucket][key] = {
|
|
113
|
+
content,
|
|
114
|
+
contentType: uploadOptions.contentType,
|
|
115
|
+
metadata: uploadOptions.metadata,
|
|
116
|
+
lastModified: new Date(),
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
etag: '"mock-etag"',
|
|
120
|
+
generation: undefined,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
98
123
|
async deleteObject(bucket, key) {
|
|
99
124
|
if (objects?.[bucket]) {
|
|
100
125
|
delete objects[bucket][key];
|
|
@@ -86,6 +86,29 @@ export class GoogleCloudStorageClient {
|
|
|
86
86
|
generation: metadata.generation ? String(metadata.generation) : undefined,
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
|
+
async uploadFile(bucket, sourcePath, key, options = {}) {
|
|
90
|
+
// bucket.upload streams the file directly from local disk to GCS — the bytes
|
|
91
|
+
// never pass through the caller, so this is binary-safe and avoids loading
|
|
92
|
+
// the file into the MCP tool-call payload.
|
|
93
|
+
const uploadOptions = { destination: key };
|
|
94
|
+
const fileMetadata = {};
|
|
95
|
+
if (options.contentType) {
|
|
96
|
+
fileMetadata.contentType = options.contentType;
|
|
97
|
+
}
|
|
98
|
+
if (options.metadata) {
|
|
99
|
+
fileMetadata.metadata = options.metadata;
|
|
100
|
+
}
|
|
101
|
+
if (Object.keys(fileMetadata).length > 0) {
|
|
102
|
+
uploadOptions.metadata = fileMetadata;
|
|
103
|
+
}
|
|
104
|
+
// When contentType is omitted, the SDK auto-detects it from the file extension.
|
|
105
|
+
const [file] = await this.storage.bucket(bucket).upload(sourcePath, uploadOptions);
|
|
106
|
+
const [metadata] = await file.getMetadata();
|
|
107
|
+
return {
|
|
108
|
+
etag: metadata.etag,
|
|
109
|
+
generation: metadata.generation ? String(metadata.generation) : undefined,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
89
112
|
async deleteObject(bucket, key) {
|
|
90
113
|
await this.storage.bucket(bucket).file(key).delete();
|
|
91
114
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import type { GCSClientFactory } from '../server.js';
|
|
4
|
+
export declare const PutObjectFromPathSchema: z.ZodObject<{
|
|
5
|
+
bucket: z.ZodString;
|
|
6
|
+
key: z.ZodString;
|
|
7
|
+
sourcePath: z.ZodString;
|
|
8
|
+
contentType: z.ZodOptional<z.ZodString>;
|
|
9
|
+
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
10
|
+
}, "strip", z.ZodTypeAny, {
|
|
11
|
+
bucket: string;
|
|
12
|
+
key: string;
|
|
13
|
+
sourcePath: string;
|
|
14
|
+
metadata?: Record<string, string> | undefined;
|
|
15
|
+
contentType?: string | undefined;
|
|
16
|
+
}, {
|
|
17
|
+
bucket: string;
|
|
18
|
+
key: string;
|
|
19
|
+
sourcePath: string;
|
|
20
|
+
metadata?: Record<string, string> | undefined;
|
|
21
|
+
contentType?: string | undefined;
|
|
22
|
+
}>;
|
|
23
|
+
export declare function putObjectFromPathTool(_server: Server, clientFactory: GCSClientFactory): {
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: "object";
|
|
28
|
+
properties: {
|
|
29
|
+
bucket: {
|
|
30
|
+
type: string;
|
|
31
|
+
description: "The name of the GCS bucket (e.g., \"my-app-data\")";
|
|
32
|
+
};
|
|
33
|
+
key: {
|
|
34
|
+
type: string;
|
|
35
|
+
description: "The destination object key (path) within the bucket (e.g., \"exports/archive.tar.gz\")";
|
|
36
|
+
};
|
|
37
|
+
sourcePath: {
|
|
38
|
+
type: string;
|
|
39
|
+
description: "Local file path to upload. The file is streamed directly from disk to GCS — its bytes never pass through this tool call, so it is binary-safe and avoids loading large files into the model context.";
|
|
40
|
+
};
|
|
41
|
+
contentType: {
|
|
42
|
+
type: string;
|
|
43
|
+
description: "MIME type of the object (e.g., \"image/png\"). If omitted, it is auto-detected from the file extension.";
|
|
44
|
+
};
|
|
45
|
+
metadata: {
|
|
46
|
+
type: string;
|
|
47
|
+
additionalProperties: {
|
|
48
|
+
type: string;
|
|
49
|
+
};
|
|
50
|
+
description: "Custom metadata as key-value pairs (e.g., {\"author\": \"system\", \"version\": \"1.0\"})";
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
required: string[];
|
|
54
|
+
};
|
|
55
|
+
handler: (args: unknown) => Promise<{
|
|
56
|
+
content: {
|
|
57
|
+
type: string;
|
|
58
|
+
text: string;
|
|
59
|
+
}[];
|
|
60
|
+
isError?: undefined;
|
|
61
|
+
} | {
|
|
62
|
+
content: {
|
|
63
|
+
type: string;
|
|
64
|
+
text: string;
|
|
65
|
+
}[];
|
|
66
|
+
isError: boolean;
|
|
67
|
+
}>;
|
|
68
|
+
};
|
|
69
|
+
//# sourceMappingURL=put-object-from-path.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { stat } from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
const PARAM_DESCRIPTIONS = {
|
|
5
|
+
bucket: 'The name of the GCS bucket (e.g., "my-app-data")',
|
|
6
|
+
key: 'The destination object key (path) within the bucket (e.g., "exports/archive.tar.gz")',
|
|
7
|
+
sourcePath: 'Local file path to upload. The file is streamed directly from disk to GCS — its bytes never pass through this tool call, so it is binary-safe and avoids loading large files into the model context.',
|
|
8
|
+
contentType: 'MIME type of the object (e.g., "image/png"). If omitted, it is auto-detected from the file extension.',
|
|
9
|
+
metadata: 'Custom metadata as key-value pairs (e.g., {"author": "system", "version": "1.0"})',
|
|
10
|
+
};
|
|
11
|
+
export const PutObjectFromPathSchema = z.object({
|
|
12
|
+
bucket: z.string().min(1).describe(PARAM_DESCRIPTIONS.bucket),
|
|
13
|
+
key: z.string().min(1).describe(PARAM_DESCRIPTIONS.key),
|
|
14
|
+
sourcePath: z.string().min(1).describe(PARAM_DESCRIPTIONS.sourcePath),
|
|
15
|
+
contentType: z.string().optional().describe(PARAM_DESCRIPTIONS.contentType),
|
|
16
|
+
metadata: z.record(z.string()).optional().describe(PARAM_DESCRIPTIONS.metadata),
|
|
17
|
+
});
|
|
18
|
+
export function putObjectFromPathTool(_server, clientFactory) {
|
|
19
|
+
return {
|
|
20
|
+
name: 'put_object_from_path',
|
|
21
|
+
description: `Upload a single local file to GCS by streaming it directly from disk (binary-safe).
|
|
22
|
+
|
|
23
|
+
Unlike put_object (which takes the object content inline as a string argument — routing every byte through the model context and requiring base64 for binary data), this tool reads from a local filesystem path and streams the bytes to GCS server-side. It is the right tool for uploading images, archives, or any large/binary file without blowing through context.
|
|
24
|
+
|
|
25
|
+
Example response:
|
|
26
|
+
{
|
|
27
|
+
"bucket": "my-bucket",
|
|
28
|
+
"key": "exports/archive.tar.gz",
|
|
29
|
+
"sourcePath": "/tmp/archive.tar.gz",
|
|
30
|
+
"size": 1048576,
|
|
31
|
+
"etag": "\\"abc123def456\\"",
|
|
32
|
+
"generation": "1234567890"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
Notes:
|
|
36
|
+
- The content type is auto-detected from the file extension unless contentType is supplied.
|
|
37
|
+
- To upload an entire directory tree, use upload_prefix instead.`,
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
bucket: { type: 'string', description: PARAM_DESCRIPTIONS.bucket },
|
|
42
|
+
key: { type: 'string', description: PARAM_DESCRIPTIONS.key },
|
|
43
|
+
sourcePath: { type: 'string', description: PARAM_DESCRIPTIONS.sourcePath },
|
|
44
|
+
contentType: { type: 'string', description: PARAM_DESCRIPTIONS.contentType },
|
|
45
|
+
metadata: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
additionalProperties: { type: 'string' },
|
|
48
|
+
description: PARAM_DESCRIPTIONS.metadata,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
required: ['bucket', 'key', 'sourcePath'],
|
|
52
|
+
},
|
|
53
|
+
handler: async (args) => {
|
|
54
|
+
try {
|
|
55
|
+
const validated = PutObjectFromPathSchema.parse(args);
|
|
56
|
+
const client = clientFactory();
|
|
57
|
+
const resolvedSource = path.resolve(validated.sourcePath);
|
|
58
|
+
const stats = await stat(resolvedSource);
|
|
59
|
+
if (!stats.isFile()) {
|
|
60
|
+
throw new Error(`Source path "${validated.sourcePath}" is not a regular file. Use upload_prefix to upload a directory.`);
|
|
61
|
+
}
|
|
62
|
+
const result = await client.uploadFile(validated.bucket, resolvedSource, validated.key, {
|
|
63
|
+
contentType: validated.contentType,
|
|
64
|
+
metadata: validated.metadata,
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
content: [
|
|
68
|
+
{
|
|
69
|
+
type: 'text',
|
|
70
|
+
text: JSON.stringify({
|
|
71
|
+
success: true,
|
|
72
|
+
message: `Uploaded ${resolvedSource} to gs://${validated.bucket}/${validated.key}`,
|
|
73
|
+
bucket: validated.bucket,
|
|
74
|
+
key: validated.key,
|
|
75
|
+
sourcePath: resolvedSource,
|
|
76
|
+
size: stats.size,
|
|
77
|
+
...result,
|
|
78
|
+
}, null, 2),
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: 'text', text: `Error uploading object from path: ${message}` }],
|
|
87
|
+
isError: true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import type { GCSClientFactory } from '../server.js';
|
|
4
|
+
export declare const UploadPrefixSchema: z.ZodObject<{
|
|
5
|
+
bucket: z.ZodString;
|
|
6
|
+
sourceDir: z.ZodString;
|
|
7
|
+
destPrefix: z.ZodOptional<z.ZodString>;
|
|
8
|
+
maxInlineEntries: z.ZodOptional<z.ZodNumber>;
|
|
9
|
+
}, "strip", z.ZodTypeAny, {
|
|
10
|
+
bucket: string;
|
|
11
|
+
sourceDir: string;
|
|
12
|
+
maxInlineEntries?: number | undefined;
|
|
13
|
+
destPrefix?: string | undefined;
|
|
14
|
+
}, {
|
|
15
|
+
bucket: string;
|
|
16
|
+
sourceDir: string;
|
|
17
|
+
maxInlineEntries?: number | undefined;
|
|
18
|
+
destPrefix?: string | undefined;
|
|
19
|
+
}>;
|
|
20
|
+
/**
|
|
21
|
+
* Build the GCS object key for a file at `relPath` (relative to the upload root),
|
|
22
|
+
* placed beneath `destPrefix`. The OS path separator is normalized to "/" so
|
|
23
|
+
* keys are well-formed regardless of platform.
|
|
24
|
+
*
|
|
25
|
+
* Example: destPrefix="uploads/", relPath="01/data.json" -> "uploads/01/data.json"
|
|
26
|
+
*/
|
|
27
|
+
export declare function keyForLocalFile(relPath: string, destPrefix: string): string;
|
|
28
|
+
export declare function uploadPrefixTool(_server: Server, clientFactory: GCSClientFactory): {
|
|
29
|
+
name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: "object";
|
|
33
|
+
properties: {
|
|
34
|
+
bucket: {
|
|
35
|
+
type: string;
|
|
36
|
+
description: "The name of the GCS bucket (e.g., \"my-app-data\")";
|
|
37
|
+
};
|
|
38
|
+
sourceDir: {
|
|
39
|
+
type: string;
|
|
40
|
+
description: "Local directory to upload recursively. Every file under it is streamed directly from disk to GCS — the bytes never pass through this tool call, so it is binary-safe and avoids loading files into the model context.";
|
|
41
|
+
};
|
|
42
|
+
destPrefix: {
|
|
43
|
+
type: string;
|
|
44
|
+
description: "The destination key prefix in the bucket (e.g., \"uploads/2024/\"). The directory tree under sourceDir is preserved beneath this prefix. Use an empty string to upload to the bucket root.";
|
|
45
|
+
};
|
|
46
|
+
maxInlineEntries: {
|
|
47
|
+
type: string;
|
|
48
|
+
minimum: number;
|
|
49
|
+
description: "Maximum number of file entries to include inline in the response manifest (default: 100). Counts and totals always reflect the full set regardless of this cap.";
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
required: string[];
|
|
53
|
+
};
|
|
54
|
+
handler: (args: unknown) => Promise<{
|
|
55
|
+
content: {
|
|
56
|
+
type: string;
|
|
57
|
+
text: string;
|
|
58
|
+
}[];
|
|
59
|
+
isError?: undefined;
|
|
60
|
+
} | {
|
|
61
|
+
content: {
|
|
62
|
+
type: string;
|
|
63
|
+
text: string;
|
|
64
|
+
}[];
|
|
65
|
+
isError: boolean;
|
|
66
|
+
}>;
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=upload-prefix.d.ts.map
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { readdir, stat } from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
const PARAM_DESCRIPTIONS = {
|
|
5
|
+
bucket: 'The name of the GCS bucket (e.g., "my-app-data")',
|
|
6
|
+
sourceDir: 'Local directory to upload recursively. Every file under it is streamed directly from disk to GCS — the bytes never pass through this tool call, so it is binary-safe and avoids loading files into the model context.',
|
|
7
|
+
destPrefix: 'The destination key prefix in the bucket (e.g., "uploads/2024/"). The directory tree under sourceDir is preserved beneath this prefix. Use an empty string to upload to the bucket root.',
|
|
8
|
+
maxInlineEntries: 'Maximum number of file entries to include inline in the response manifest (default: 100). Counts and totals always reflect the full set regardless of this cap.',
|
|
9
|
+
};
|
|
10
|
+
// Cap how many object paths are echoed back inline so that uploading thousands
|
|
11
|
+
// of files does not produce an enormous tool response.
|
|
12
|
+
const DEFAULT_MAX_INLINE_ENTRIES = 100;
|
|
13
|
+
export const UploadPrefixSchema = z.object({
|
|
14
|
+
bucket: z.string().min(1).describe(PARAM_DESCRIPTIONS.bucket),
|
|
15
|
+
sourceDir: z.string().min(1).describe(PARAM_DESCRIPTIONS.sourceDir),
|
|
16
|
+
destPrefix: z.string().optional().describe(PARAM_DESCRIPTIONS.destPrefix),
|
|
17
|
+
maxInlineEntries: z
|
|
18
|
+
.number()
|
|
19
|
+
.int()
|
|
20
|
+
.min(0)
|
|
21
|
+
.optional()
|
|
22
|
+
.describe(PARAM_DESCRIPTIONS.maxInlineEntries),
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* Build the GCS object key for a file at `relPath` (relative to the upload root),
|
|
26
|
+
* placed beneath `destPrefix`. The OS path separator is normalized to "/" so
|
|
27
|
+
* keys are well-formed regardless of platform.
|
|
28
|
+
*
|
|
29
|
+
* Example: destPrefix="uploads/", relPath="01/data.json" -> "uploads/01/data.json"
|
|
30
|
+
*/
|
|
31
|
+
export function keyForLocalFile(relPath, destPrefix) {
|
|
32
|
+
const normalizedRel = relPath.split(path.sep).join('/').replace(/^\/+/, '');
|
|
33
|
+
if (!destPrefix) {
|
|
34
|
+
return normalizedRel;
|
|
35
|
+
}
|
|
36
|
+
const cleanPrefix = destPrefix.replace(/\/+$/, '');
|
|
37
|
+
return `${cleanPrefix}/${normalizedRel}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Recursively collect absolute paths of every regular file under `dir`.
|
|
41
|
+
*
|
|
42
|
+
* Symbolic links that resolve to regular files are followed and uploaded, so a
|
|
43
|
+
* tree of symlinked files is not silently dropped. Symbolic links that resolve
|
|
44
|
+
* to directories are skipped — following them risks walking out of the tree or
|
|
45
|
+
* into cycles. Broken/unresolvable symlinks are skipped here; if one happens to
|
|
46
|
+
* be a real file path the per-file upload loop will surface the failure in the
|
|
47
|
+
* manifest's `errors`.
|
|
48
|
+
*/
|
|
49
|
+
async function collectFiles(dir) {
|
|
50
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
51
|
+
const files = [];
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
const fullPath = path.join(dir, entry.name);
|
|
54
|
+
if (entry.isDirectory()) {
|
|
55
|
+
files.push(...(await collectFiles(fullPath)));
|
|
56
|
+
}
|
|
57
|
+
else if (entry.isFile()) {
|
|
58
|
+
files.push(fullPath);
|
|
59
|
+
}
|
|
60
|
+
else if (entry.isSymbolicLink()) {
|
|
61
|
+
// stat() follows the link to its target; lstat (via withFileTypes) does not.
|
|
62
|
+
try {
|
|
63
|
+
const target = await stat(fullPath);
|
|
64
|
+
if (target.isFile()) {
|
|
65
|
+
files.push(fullPath);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Broken symlink — skip silently.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return files;
|
|
74
|
+
}
|
|
75
|
+
export function uploadPrefixTool(_server, clientFactory) {
|
|
76
|
+
return {
|
|
77
|
+
name: 'upload_prefix',
|
|
78
|
+
description: `Recursively upload every file under a local directory to GCS, preserving the directory structure as key paths beneath a destination prefix.
|
|
79
|
+
|
|
80
|
+
Unlike put_object (which takes content inline as a string and routes every byte through the model context), this tool streams each file directly from disk to GCS server-side. It is the right tool for bulk-uploading many files or folders — including binary files like images — without blowing through context.
|
|
81
|
+
|
|
82
|
+
Returns a manifest:
|
|
83
|
+
{
|
|
84
|
+
"bucket": "my-bucket",
|
|
85
|
+
"sourceDir": "/tmp/exports",
|
|
86
|
+
"destPrefix": "uploads/",
|
|
87
|
+
"objectCount": 1234,
|
|
88
|
+
"totalBytes": 5678901,
|
|
89
|
+
"files": [
|
|
90
|
+
{ "localPath": "/tmp/exports/01/data.json", "key": "uploads/01/data.json", "size": 1234 }
|
|
91
|
+
],
|
|
92
|
+
"filesTruncated": true,
|
|
93
|
+
"errors": []
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
Notes:
|
|
97
|
+
- Content types are auto-detected from each file's extension.
|
|
98
|
+
- Symbolic links to files are followed and uploaded; symbolic links to directories are skipped (to avoid following links out of the tree or into cycles).
|
|
99
|
+
- Per-file upload failures are collected in "errors" without aborting the whole batch.
|
|
100
|
+
- The "files" list is capped (see maxInlineEntries); "objectCount" and "totalBytes" always reflect the full upload.`,
|
|
101
|
+
inputSchema: {
|
|
102
|
+
type: 'object',
|
|
103
|
+
properties: {
|
|
104
|
+
bucket: { type: 'string', description: PARAM_DESCRIPTIONS.bucket },
|
|
105
|
+
sourceDir: { type: 'string', description: PARAM_DESCRIPTIONS.sourceDir },
|
|
106
|
+
destPrefix: { type: 'string', description: PARAM_DESCRIPTIONS.destPrefix },
|
|
107
|
+
maxInlineEntries: {
|
|
108
|
+
type: 'number',
|
|
109
|
+
minimum: 0,
|
|
110
|
+
description: PARAM_DESCRIPTIONS.maxInlineEntries,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
required: ['bucket', 'sourceDir'],
|
|
114
|
+
},
|
|
115
|
+
handler: async (args) => {
|
|
116
|
+
try {
|
|
117
|
+
const validated = UploadPrefixSchema.parse(args);
|
|
118
|
+
const client = clientFactory();
|
|
119
|
+
const maxInlineEntries = validated.maxInlineEntries ?? DEFAULT_MAX_INLINE_ENTRIES;
|
|
120
|
+
const destPrefix = validated.destPrefix ?? '';
|
|
121
|
+
const resolvedRoot = path.resolve(validated.sourceDir);
|
|
122
|
+
const rootStats = await stat(resolvedRoot);
|
|
123
|
+
if (!rootStats.isDirectory()) {
|
|
124
|
+
throw new Error(`Source path "${validated.sourceDir}" is not a directory. Use put_object_from_path to upload a single file.`);
|
|
125
|
+
}
|
|
126
|
+
const localFiles = await collectFiles(resolvedRoot);
|
|
127
|
+
const files = [];
|
|
128
|
+
const errors = [];
|
|
129
|
+
let totalBytes = 0;
|
|
130
|
+
for (const localPath of localFiles) {
|
|
131
|
+
const rel = path.relative(resolvedRoot, localPath);
|
|
132
|
+
const key = keyForLocalFile(rel, destPrefix);
|
|
133
|
+
try {
|
|
134
|
+
const fileStats = await stat(localPath);
|
|
135
|
+
await client.uploadFile(validated.bucket, localPath, key);
|
|
136
|
+
totalBytes += fileStats.size;
|
|
137
|
+
files.push({ localPath, key, size: fileStats.size });
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
141
|
+
errors.push({ localPath, error: message });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const manifest = {
|
|
145
|
+
bucket: validated.bucket,
|
|
146
|
+
sourceDir: resolvedRoot,
|
|
147
|
+
destPrefix,
|
|
148
|
+
objectCount: files.length,
|
|
149
|
+
totalBytes,
|
|
150
|
+
files: files.slice(0, maxInlineEntries),
|
|
151
|
+
filesTruncated: files.length > maxInlineEntries,
|
|
152
|
+
errors,
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
content: [
|
|
156
|
+
{
|
|
157
|
+
type: 'text',
|
|
158
|
+
text: JSON.stringify(manifest, null, 2),
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
165
|
+
return {
|
|
166
|
+
content: [{ type: 'text', text: `Error uploading prefix: ${message}` }],
|
|
167
|
+
isError: true,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
package/shared/tools.js
CHANGED
|
@@ -5,6 +5,8 @@ import { getObjectTool } from './tools/get-object.js';
|
|
|
5
5
|
import { downloadPrefixTool } from './tools/download-prefix.js';
|
|
6
6
|
import { downloadObjectTool } from './tools/download-object.js';
|
|
7
7
|
import { putObjectTool } from './tools/put-object.js';
|
|
8
|
+
import { putObjectFromPathTool } from './tools/put-object-from-path.js';
|
|
9
|
+
import { uploadPrefixTool } from './tools/upload-prefix.js';
|
|
8
10
|
import { deleteObjectTool } from './tools/delete-object.js';
|
|
9
11
|
import { createBucketTool } from './tools/create-bucket.js';
|
|
10
12
|
import { deleteBucketTool } from './tools/delete-bucket.js';
|
|
@@ -52,6 +54,8 @@ const ALL_TOOLS = [
|
|
|
52
54
|
{ factory: headBucketTool, groups: ['readonly'], bucketLevelOnly: true },
|
|
53
55
|
// Write operations (non-destructive)
|
|
54
56
|
{ factory: putObjectTool, groups: ['readwrite'], bucketParams: ['bucket'] },
|
|
57
|
+
{ factory: putObjectFromPathTool, groups: ['readwrite'], bucketParams: ['bucket'] },
|
|
58
|
+
{ factory: uploadPrefixTool, groups: ['readwrite'], bucketParams: ['bucket'] },
|
|
55
59
|
{ factory: copyObjectTool, groups: ['readwrite'], bucketParams: ['sourceBucket', 'destBucket'] },
|
|
56
60
|
{ factory: createBucketTool, groups: ['readwrite'], bucketLevelOnly: true },
|
|
57
61
|
// Delete operations
|