Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/connector-google-drive.md
Original file line number Diff line number Diff line change
@@ -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).
48 changes: 48 additions & 0 deletions packages/connector-google-drive/README.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 57 additions & 0 deletions packages/connector-google-drive/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
68 changes: 68 additions & 0 deletions packages/connector-google-drive/src/client.ts
Original file line number Diff line number Diff line change
@@ -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<T = unknown>(
ctx: ConnectorContext,
method: string,
path: string,
body?: unknown,
): Promise<T> {
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<string> {
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()
}
187 changes: 187 additions & 0 deletions packages/connector-google-drive/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
Loading
Loading