From 8576ff071aaeb84a5bfa24a16f0ab481c2d4e2a5 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Tue, 30 Jun 2026 23:51:03 +0300 Subject: [PATCH] feat(connector-google-drive): Google Drive connector on @gemstack/connectors Browse, read, and share Drive files over the Drive REST API v3. Nine tools (get-about, list-files, search-files, get-file, get-file-content, list-permissions, create-folder, share-file, trash-file) on the @gemstack/connectors contract; OAuth bearer via the mount credentials seam. Closes #89 --- .changeset/connector-google-drive.md | 5 + packages/connector-google-drive/README.md | 48 ++++ packages/connector-google-drive/package.json | 57 ++++ packages/connector-google-drive/src/client.ts | 68 +++++ .../connector-google-drive/src/index.test.ts | 187 ++++++++++++++ packages/connector-google-drive/src/index.ts | 243 ++++++++++++++++++ .../tsconfig.build.json | 6 + packages/connector-google-drive/tsconfig.json | 5 + .../connector-google-drive/tsconfig.test.json | 5 + pnpm-lock.yaml | 22 ++ 10 files changed, 646 insertions(+) create mode 100644 .changeset/connector-google-drive.md create mode 100644 packages/connector-google-drive/README.md create mode 100644 packages/connector-google-drive/package.json create mode 100644 packages/connector-google-drive/src/client.ts create mode 100644 packages/connector-google-drive/src/index.test.ts create mode 100644 packages/connector-google-drive/src/index.ts create mode 100644 packages/connector-google-drive/tsconfig.build.json create mode 100644 packages/connector-google-drive/tsconfig.json create mode 100644 packages/connector-google-drive/tsconfig.test.json diff --git a/.changeset/connector-google-drive.md b/.changeset/connector-google-drive.md new file mode 100644 index 0000000..183b435 --- /dev/null +++ b/.changeset/connector-google-drive.md @@ -0,0 +1,5 @@ +--- +"@gemstack/connector-google-drive": minor +--- + +New package: the Google Drive connector for GemStack AI orchestration. Browse, read, and share Drive files over the Drive REST API (v3) — `get-about`, `list-files`, `search-files`, `get-file`, `get-file-content` (Docs/Sheets/Slides exported to text, other files downloaded), `list-permissions`, `create-folder`, `share-file`, `trash-file`. Built with `@gemstack/connectors`; consumes a Google OAuth 2.0 access token via the mount `credentials` seam. Second connector on the contract (epic #86). diff --git a/packages/connector-google-drive/README.md b/packages/connector-google-drive/README.md new file mode 100644 index 0000000..bde1c0d --- /dev/null +++ b/packages/connector-google-drive/README.md @@ -0,0 +1,48 @@ +# @gemstack/connector-google-drive + +A Google Drive connector for GemStack AI orchestration. Browse, read, and share Drive files over the Drive REST API (v3). Built with [`@gemstack/connectors`](../connectors); the result is a standard `@gemstack/mcp` server. + +## Install + +```bash +npm i @gemstack/connector-google-drive @gemstack/connectors @gemstack/mcp +``` + +## Use + +```ts +import { mountConnectors } from '@gemstack/connectors' +import { Mcp } from '@gemstack/mcp' +import drive from '@gemstack/connector-google-drive' + +const Server = mountConnectors([drive], { + // A Google OAuth 2.0 access token with a Drive scope. + credentials: () => ({ token: process.env.GOOGLE_ACCESS_TOKEN }), +}) + +Mcp.web('/mcp/drive', Server) +``` + +Tools are exposed namespaced by connector id, e.g. `google-drive_list-files`. + +## Auth + +Drive has no static API key — it is OAuth 2.0 only. The connector declares `auth: { type: 'oauth', scopes: ['https://www.googleapis.com/auth/drive'] }` and consumes a bearer token from `ctx.auth.token`; it does no OAuth handshake itself. Use `drive.readonly` if you only need the read tools. To protect a web endpoint and obtain the token, wrap it with `@gemstack/mcp`'s `oauth2McpMiddleware` + `registerOAuth2Metadata` and feed the verified token through the mount `credentials` option. + +## Tools + +| Tool | Kind | Description | +|---|---|---| +| `get-about` | read | Authenticated user + storage usage | +| `list-files` | read | List files (folder / raw query scoped) | +| `search-files` | read | Search by name and full-text content | +| `get-file` | read | Metadata for one file or folder | +| `get-file-content` | read | File as text (Docs exported, others downloaded) | +| `list-permissions` | read | Who has access to a file | +| `create-folder` | write | Create a folder | +| `share-file` | write | Grant access (create a permission) | +| `trash-file` | write (destructive) | Move a file to the trash (reversible) | + +Read tools are annotated `readOnly` so an agent can auto-approve them; write tools are not, and `trash-file` is marked `destructive`. + +Responses are slimmed to the fields an agent needs (no raw API envelopes) to keep token usage down. diff --git a/packages/connector-google-drive/package.json b/packages/connector-google-drive/package.json new file mode 100644 index 0000000..1e97830 --- /dev/null +++ b/packages/connector-google-drive/package.json @@ -0,0 +1,57 @@ +{ + "name": "@gemstack/connector-google-drive", + "version": "0.0.0", + "description": "Google Drive connector for GemStack AI orchestration: browse, read, and share Drive files over the Drive REST API. Built with @gemstack/connectors.", + "keywords": [ + "mcp", + "connector", + "google-drive", + "ai", + "agents", + "orchestration", + "gemstack" + ], + "license": "MIT", + "homepage": "https://github.com/gemstack-land/gemstack/tree/main/packages/connector-google-drive#readme", + "bugs": { + "url": "https://github.com/gemstack-land/gemstack/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/gemstack-land/gemstack", + "directory": "packages/connector-google-drive" + }, + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "files": [ + "dist" + ], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsc -p tsconfig.build.json --watch", + "typecheck": "tsc --noEmit", + "test": "tsc -p tsconfig.test.json && cd dist-test && node --test", + "clean": "rm -rf dist dist-test" + }, + "dependencies": { + "@gemstack/connectors": "workspace:^", + "@gemstack/mcp": "workspace:^", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "reflect-metadata": "^0.2.0", + "typescript": "^5.4.0" + }, + "author": "Suleiman Shahbari" +} diff --git a/packages/connector-google-drive/src/client.ts b/packages/connector-google-drive/src/client.ts new file mode 100644 index 0000000..bce5e39 --- /dev/null +++ b/packages/connector-google-drive/src/client.ts @@ -0,0 +1,68 @@ +import type { ConnectorContext } from '@gemstack/connectors' + +const API = 'https://www.googleapis.com/drive/v3' + +/** Thrown when the Google Drive API returns a non-2xx response. */ +export class GoogleDriveError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message) + this.name = 'GoogleDriveError' + } +} + +function bearer(ctx: ConnectorContext): string { + const token = ctx.auth.token + if (!token) { + throw new GoogleDriveError( + 401, + 'no Google access token available — provide an OAuth bearer via the mount `credentials` option', + ) + } + return token +} + +/** + * Minimal Google Drive REST (v3) client over global `fetch`. The bearer comes + * from the connector context (`ctx.auth.token`) — a Google OAuth 2.0 access + * token. No SDK dependency, so the connector stays light. + */ +export async function gd( + ctx: ConnectorContext, + method: string, + path: string, + body?: unknown, +): Promise { + const token = bearer(ctx) + const res = await fetch(`${API}${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }) + if (!res.ok) { + const detail = await res.text().catch(() => '') + throw new GoogleDriveError( + res.status, + `${method} ${path} -> ${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`, + ) + } + if (res.status === 204) return undefined as T + return (await res.json()) as T +} + +/** GET a file's raw bytes as text (for `alt=media` downloads and Docs exports). */ +export async function gdText(ctx: ConnectorContext, path: string): Promise { + const token = bearer(ctx) + const res = await fetch(`${API}${path}`, { method: 'GET', headers: { Authorization: `Bearer ${token}` } }) + if (!res.ok) { + const detail = await res.text().catch(() => '') + throw new GoogleDriveError(res.status, `GET ${path} -> ${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`) + } + return await res.text() +} diff --git a/packages/connector-google-drive/src/index.test.ts b/packages/connector-google-drive/src/index.test.ts new file mode 100644 index 0000000..be86005 --- /dev/null +++ b/packages/connector-google-drive/src/index.test.ts @@ -0,0 +1,187 @@ +import 'reflect-metadata' +import { test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { McpTestClient } from '@gemstack/mcp/testing' +import type { McpToolResult } from '@gemstack/mcp' +import { mountConnectors } from '@gemstack/connectors' +import drive from './index.js' + +const realFetch = globalThis.fetch + +interface Captured { + url: string + method: string + body?: unknown +} +let calls: Captured[] = [] + +/** Install a fetch stub. `handler` maps a request to `{ status?, body }`. */ +function mockFetch(handler: (url: string, method: string) => { status?: number; body: unknown }) { + globalThis.fetch = (async (url: string | URL, init: RequestInit = {}) => { + const method = init.method ?? 'GET' + calls.push({ url: String(url), method, body: init.body ? JSON.parse(String(init.body)) : undefined }) + const { status = 200, body } = handler(String(url), method) + return { + ok: status >= 200 && status < 300, + status, + statusText: 'OK', + json: async () => body, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + } + }) as unknown as typeof fetch +} + +function client(token: string | undefined = 'tok') { + return new McpTestClient(mountConnectors([drive], { credentials: () => ({ token }) })) +} + +function json(result: McpToolResult): any { + const first = result.content[0] + return first && first.type === 'text' ? JSON.parse(first.text) : undefined +} + +/** Decode a captured request URL, restoring `+`-encoded spaces for readable matching. */ +function urlOf(i: number): string { + return decodeURIComponent(calls[i]!.url).replace(/\+/g, ' ') +} + +beforeEach(() => { + calls = [] +}) +afterEach(() => { + globalThis.fetch = realFetch +}) + +test('tools are namespaced and writes are not marked read-only', async () => { + const tools = await client().listTools() + const names = tools.map((t) => t.name).sort() + assert.ok(names.includes('google-drive_list-files')) + assert.ok(names.includes('google-drive_create-folder')) + assert.equal(tools.length, 9) + const read = tools.find((t) => t.name === 'google-drive_get-file') + const write = tools.find((t) => t.name === 'google-drive_create-folder') + const trash = tools.find((t) => t.name === 'google-drive_trash-file') + assert.equal(read?.annotations?.readOnlyHint, true) + assert.notEqual(write?.annotations?.readOnlyHint, true) + assert.equal(trash?.annotations?.destructiveHint, true) +}) + +test('list-files scopes to a folder, excludes trashed, and slims the payload', async () => { + mockFetch(() => ({ + body: { + files: [ + { + id: 'f1', + name: 'notes.txt', + mimeType: 'text/plain', + size: '12', + modifiedTime: '2026-06-30T00:00:00Z', + owners: [{ emailAddress: 'a@x.com' }], + webViewLink: 'u1', + }, + { id: 'f2', name: 'sub', mimeType: 'application/vnd.google-apps.folder', webViewLink: 'u2' }, + ], + }, + })) + const res = json(await client().callTool('google-drive_list-files', { folderId: 'F', limit: 10 })) + assert.equal(res.length, 2) + assert.deepEqual(res[0], { + id: 'f1', + name: 'notes.txt', + mimeType: 'text/plain', + isFolder: false, + size: 12, + modifiedTime: '2026-06-30T00:00:00Z', + owners: ['a@x.com'], + url: 'u1', + }) + assert.equal(res[1].isFolder, true) + const url = urlOf(0) + assert.match(url, /'F' in parents and trashed = false/) + assert.match(url, /pageSize=10/) +}) + +test('search-files builds a name + fullText query and escapes quotes', async () => { + mockFetch(() => ({ body: { files: [] } })) + await client().callTool('google-drive_search-files', { text: "o'brien" }) + const url = urlOf(0) + assert.match(url, /name contains 'o\\'brien' or fullText contains 'o\\'brien'/) + assert.match(url, /trashed = false/) +}) + +test('get-file-content exports a Google Doc to text', async () => { + mockFetch((url) => { + if (url.includes('/export')) return { body: 'hello world' } + return { body: { id: 'd1', name: 'Doc', mimeType: 'application/vnd.google-apps.document' } } + }) + const res = json(await client().callTool('google-drive_get-file-content', { fileId: 'd1' })) + assert.equal(res.content, 'hello world') + assert.equal(res.name, 'Doc') + assert.match(decodeURIComponent(calls[1]!.url), /\/files\/d1\/export\?mimeType=text\/plain/) +}) + +test('get-file-content downloads a regular file via alt=media', async () => { + mockFetch((url) => { + if (url.includes('alt=media')) return { body: 'raw bytes' } + return { body: { id: 't1', name: 'a.txt', mimeType: 'text/plain' } } + }) + const res = json(await client().callTool('google-drive_get-file-content', { fileId: 't1' })) + assert.equal(res.content, 'raw bytes') + assert.match(calls[1]!.url, /\/files\/t1\?alt=media$/) +}) + +test('get-file-content refuses a folder', async () => { + mockFetch(() => ({ body: { id: 'x', name: 'Stuff', mimeType: 'application/vnd.google-apps.folder' } })) + const res = json(await client().callTool('google-drive_get-file-content', { fileId: 'x' })) + assert.match(res.error, /folder/) + assert.equal(calls.length, 1) +}) + +test('create-folder posts the folder mime type and parent', async () => { + mockFetch(() => ({ body: { id: 'new', name: 'Reports', webViewLink: 'folder-url' } })) + const res = json(await client().callTool('google-drive_create-folder', { name: 'Reports', parentId: 'P' })) + assert.deepEqual(res, { id: 'new', name: 'Reports', url: 'folder-url' }) + assert.equal(calls[0]!.method, 'POST') + assert.deepEqual(calls[0]!.body, { + name: 'Reports', + mimeType: 'application/vnd.google-apps.folder', + parents: ['P'], + }) +}) + +test('share-file posts a permission and validates the email requirement', async () => { + mockFetch(() => ({ body: { id: 'p1', role: 'writer', type: 'user' } })) + const ok = json( + await client().callTool('google-drive_share-file', { + fileId: 'F', + role: 'writer', + emailAddress: 'b@x.com', + }), + ) + assert.deepEqual(ok, { id: 'p1', role: 'writer', type: 'user' }) + assert.deepEqual(calls[0]!.body, { role: 'writer', type: 'user', emailAddress: 'b@x.com' }) + + calls = [] + const bad = json(await client().callTool('google-drive_share-file', { fileId: 'F' })) + assert.match(bad.error, /requires an emailAddress/) + assert.equal(calls.length, 0) +}) + +test('trash-file PATCHes trashed = true', async () => { + mockFetch(() => ({ body: { id: 'F', name: 'old.txt', trashed: true } })) + const res = json(await client().callTool('google-drive_trash-file', { fileId: 'F' })) + assert.deepEqual(res, { id: 'F', name: 'old.txt', trashed: true }) + assert.equal(calls[0]!.method, 'PATCH') + assert.deepEqual(calls[0]!.body, { trashed: true }) +}) + +test('a missing token fails the call with a clear error', async () => { + mockFetch(() => ({ body: {} })) + const tokenless = new McpTestClient(mountConnectors([drive], { credentials: () => ({}) })) + await assert.rejects(() => tokenless.callTool('google-drive_get-about', {}), /no Google access token/) +}) + +test('a non-2xx response surfaces status and detail', async () => { + mockFetch(() => ({ status: 404, body: 'File not found' })) + await assert.rejects(() => client().callTool('google-drive_get-file', { fileId: 'nope' }), /404/) +}) diff --git a/packages/connector-google-drive/src/index.ts b/packages/connector-google-drive/src/index.ts new file mode 100644 index 0000000..1c59f48 --- /dev/null +++ b/packages/connector-google-drive/src/index.ts @@ -0,0 +1,243 @@ +import { defineConnector } from '@gemstack/connectors' +import { z } from 'zod' +import { gd, gdText } from './client.js' + +export { GoogleDriveError } from './client.js' + +const enc = encodeURIComponent +const FOLDER_MIME = 'application/vnd.google-apps.folder' + +/** Fields requested for a file listing/metadata, kept to what an agent needs. */ +const FILE_FIELDS = 'id,name,mimeType,size,modifiedTime,owners(emailAddress),webViewLink' + +/** Escape a string for use inside a single-quoted Drive query literal. */ +const driveStr = (s: string) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + +/** Slim a Drive file resource down to the fields an agent needs. */ +function slimFile(f: Record) { + return { + id: f.id, + name: f.name, + mimeType: f.mimeType, + isFolder: f.mimeType === FOLDER_MIME, + size: f.size != null ? Number(f.size) : undefined, + modifiedTime: f.modifiedTime, + owners: Array.isArray(f.owners) ? f.owners.map((o: any) => o?.emailAddress).filter(Boolean) : undefined, + url: f.webViewLink, + } +} + +/** Map a Google editor mime type to the text format it can be exported as. */ +function exportMimeFor(mime: string): string | undefined { + switch (mime) { + case 'application/vnd.google-apps.document': + return 'text/plain' + case 'application/vnd.google-apps.spreadsheet': + return 'text/csv' + case 'application/vnd.google-apps.presentation': + return 'text/plain' + default: + return undefined + } +} + +/** + * Google Drive connector: browse, read, and share Drive files. + * + * Auth is a Google OAuth 2.0 access token. Drive has no static API key, so the + * orchestrator supplies a bearer via the mount `credentials` option (the same + * seam every connector uses). + */ +export default defineConnector({ + id: 'google-drive', + name: 'Google Drive', + instructions: 'Browse, read, and share files in Google Drive.', + auth: { + type: 'oauth', + scopes: ['https://www.googleapis.com/auth/drive'], + description: + 'Google OAuth 2.0 access token with a Drive scope (`drive` for full access, `drive.readonly` for read-only).', + }, + tools: [ + { + name: 'get-about', + description: "Get the authenticated user's identity and Drive storage usage.", + schema: z.object({}), + annotations: { readOnly: true, openWorld: true }, + handle: async (_input: Record, ctx) => { + const a = await gd>( + ctx, + 'GET', + '/about?fields=user(displayName,emailAddress),storageQuota(limit,usage)', + ) + return { + user: a.user?.displayName, + email: a.user?.emailAddress, + storageUsage: a.storageQuota?.usage != null ? Number(a.storageQuota.usage) : undefined, + storageLimit: a.storageQuota?.limit != null ? Number(a.storageQuota.limit) : undefined, + } + }, + }, + { + name: 'list-files', + description: + 'List files (newest first). Optionally scope to a folder, or pass a raw Drive `query` expression. Trashed files are excluded by default.', + schema: z.object({ + folderId: z.string().min(1).optional(), + query: z.string().min(1).optional(), + includeTrashed: z.boolean().optional(), + limit: z.number().int().min(1).max(100).optional(), + }), + annotations: { readOnly: true, openWorld: true }, + handle: async ( + input: { folderId?: string; query?: string; includeTrashed?: boolean; limit?: number }, + ctx, + ) => { + const clauses: string[] = [] + if (input.folderId) clauses.push(`'${driveStr(input.folderId)}' in parents`) + if (!input.includeTrashed) clauses.push('trashed = false') + if (input.query) clauses.push(`(${input.query})`) + const params = new URLSearchParams({ + pageSize: String(input.limit ?? 30), + orderBy: 'modifiedTime desc', + fields: `files(${FILE_FIELDS})`, + }) + if (clauses.length) params.set('q', clauses.join(' and ')) + const res = await gd<{ files?: Record[] }>(ctx, 'GET', `/files?${params}`) + return (res.files ?? []).map(slimFile) + }, + }, + { + name: 'search-files', + description: 'Search Drive by file name and full-text content. Trashed files are excluded.', + schema: z.object({ text: z.string().min(1), limit: z.number().int().min(1).max(100).optional() }), + annotations: { readOnly: true, openWorld: true }, + handle: async (input: { text: string; limit?: number }, ctx) => { + const t = driveStr(input.text) + const params = new URLSearchParams({ + q: `(name contains '${t}' or fullText contains '${t}') and trashed = false`, + pageSize: String(input.limit ?? 20), + orderBy: 'modifiedTime desc', + fields: `files(${FILE_FIELDS})`, + }) + const res = await gd<{ files?: Record[] }>(ctx, 'GET', `/files?${params}`) + return (res.files ?? []).map(slimFile) + }, + }, + { + name: 'get-file', + description: 'Get metadata for a single file or folder by id.', + schema: z.object({ fileId: z.string().min(1) }), + annotations: { readOnly: true, openWorld: true }, + handle: async (input: { fileId: string }, ctx) => { + const f = await gd>( + ctx, + 'GET', + `/files/${enc(input.fileId)}?fields=${FILE_FIELDS},parents,description`, + ) + return { ...slimFile(f), parents: f.parents, description: f.description } + }, + }, + { + name: 'get-file-content', + description: + 'Read a file as text. Google Docs/Sheets/Slides are exported (to text/CSV); other files are downloaded directly.', + schema: z.object({ fileId: z.string().min(1) }), + annotations: { readOnly: true, openWorld: true }, + handle: async (input: { fileId: string }, ctx) => { + const id = enc(input.fileId) + const meta = await gd>(ctx, 'GET', `/files/${id}?fields=id,name,mimeType`) + const mime: string = meta.mimeType ?? '' + if (mime === FOLDER_MIME) return { error: `"${meta.name}" is a folder, not a file` } + let content: string + if (mime.startsWith('application/vnd.google-apps.')) { + const exportMime = exportMimeFor(mime) + if (!exportMime) return { error: `cannot export ${mime} as text` } + content = await gdText(ctx, `/files/${id}/export?mimeType=${enc(exportMime)}`) + } else { + content = await gdText(ctx, `/files/${id}?alt=media`) + } + return { id: meta.id, name: meta.name, mimeType: mime, content } + }, + }, + { + name: 'list-permissions', + description: 'List who has access to a file or folder.', + schema: z.object({ fileId: z.string().min(1) }), + annotations: { readOnly: true, openWorld: true }, + handle: async (input: { fileId: string }, ctx) => { + const res = await gd<{ permissions?: Record[] }>( + ctx, + 'GET', + `/files/${enc(input.fileId)}/permissions?fields=permissions(id,type,role,emailAddress,domain)`, + ) + return (res.permissions ?? []).map((p) => ({ + id: p.id, + type: p.type, + role: p.role, + emailAddress: p.emailAddress, + domain: p.domain, + })) + }, + }, + { + name: 'create-folder', + description: 'Create a new folder, optionally inside a parent folder.', + schema: z.object({ name: z.string().min(1), parentId: z.string().min(1).optional() }), + annotations: { openWorld: true }, + handle: async (input: { name: string; parentId?: string }, ctx) => { + const payload: Record = { name: input.name, mimeType: FOLDER_MIME } + if (input.parentId) payload.parents = [input.parentId] + const f = await gd>(ctx, 'POST', `/files?fields=id,name,webViewLink`, payload) + return { id: f.id, name: f.name, url: f.webViewLink } + }, + }, + { + name: 'share-file', + description: 'Grant access to a file or folder by creating a permission.', + schema: z.object({ + fileId: z.string().min(1), + role: z.enum(['reader', 'commenter', 'writer', 'owner']).optional(), + type: z.enum(['user', 'group', 'domain', 'anyone']).optional(), + emailAddress: z.string().min(1).optional(), + domain: z.string().min(1).optional(), + }), + annotations: { openWorld: true }, + handle: async ( + input: { fileId: string; role?: string; type?: string; emailAddress?: string; domain?: string }, + ctx, + ) => { + const type = input.type ?? 'user' + if ((type === 'user' || type === 'group') && !input.emailAddress) { + return { error: `type "${type}" requires an emailAddress` } + } + if (type === 'domain' && !input.domain) return { error: 'type "domain" requires a domain' } + const payload: Record = { role: input.role ?? 'reader', type } + if (input.emailAddress) payload.emailAddress = input.emailAddress + if (input.domain) payload.domain = input.domain + const p = await gd>( + ctx, + 'POST', + `/files/${enc(input.fileId)}/permissions?fields=id,role,type`, + payload, + ) + return { id: p.id, role: p.role, type: p.type } + }, + }, + { + name: 'trash-file', + description: 'Move a file or folder to the trash (reversible).', + schema: z.object({ fileId: z.string().min(1) }), + annotations: { destructive: true, openWorld: true }, + handle: async (input: { fileId: string }, ctx) => { + const f = await gd>( + ctx, + 'PATCH', + `/files/${enc(input.fileId)}?fields=id,name,trashed`, + { trashed: true }, + ) + return { id: f.id, name: f.name, trashed: f.trashed } + }, + }, + ], +}) diff --git a/packages/connector-google-drive/tsconfig.build.json b/packages/connector-google-drive/tsconfig.build.json new file mode 100644 index 0000000..e578064 --- /dev/null +++ b/packages/connector-google-drive/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/connector-google-drive/tsconfig.json b/packages/connector-google-drive/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/connector-google-drive/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/connector-google-drive/tsconfig.test.json b/packages/connector-google-drive/tsconfig.test.json new file mode 100644 index 0000000..eebda2f --- /dev/null +++ b/packages/connector-google-drive/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist-test", "rootDir": "src" }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27a4f90..60de86e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -194,6 +194,28 @@ importers: specifier: ^5.4.0 version: 5.9.3 + packages/connector-google-drive: + dependencies: + '@gemstack/connectors': + specifier: workspace:^ + version: link:../connectors + '@gemstack/mcp': + specifier: workspace:^ + version: link:../mcp + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + reflect-metadata: + specifier: ^0.2.0 + version: 0.2.2 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + packages/connectors: dependencies: '@gemstack/mcp':