From a70cc3f4ed588121fe0cb8a20db399aec0a23725 Mon Sep 17 00:00:00 2001 From: smartchoice Date: Tue, 10 Mar 2026 12:59:20 +0800 Subject: [PATCH] feat: add MCP server for Google Workspace Adds a TypeScript MCP (Model Context Protocol) server that wraps the gws CLI, enabling any MCP client (Claude Desktop, Cursor, Zed, etc.) to interact with Google Workspace APIs as tools. Features: - Per-service tools (gws_drive, gws_gmail, gws_calendar, etc.) - Schema introspection tool for API discovery - Generic gws_run tool for advanced commands - Auth status check tool - Services resource listing The server delegates to the gws binary via exec, so all existing features (auth, pagination, dry-run, Model Armor) work out of the box. --- .changeset/add-mcp-server.md | 5 + mcp-server/.gitignore | 2 + mcp-server/README.md | 131 +++++++++++++++++ mcp-server/package.json | 29 ++++ mcp-server/src/index.ts | 275 +++++++++++++++++++++++++++++++++++ mcp-server/src/which.ts | 28 ++++ mcp-server/tsconfig.json | 14 ++ 7 files changed, 484 insertions(+) create mode 100644 .changeset/add-mcp-server.md create mode 100644 mcp-server/.gitignore create mode 100644 mcp-server/README.md create mode 100644 mcp-server/package.json create mode 100644 mcp-server/src/index.ts create mode 100644 mcp-server/src/which.ts create mode 100644 mcp-server/tsconfig.json diff --git a/.changeset/add-mcp-server.md b/.changeset/add-mcp-server.md new file mode 100644 index 0000000..5564132 --- /dev/null +++ b/.changeset/add-mcp-server.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add MCP (Model Context Protocol) server — enables Claude Desktop, Cursor, Zed, and other MCP clients to use Google Workspace APIs as tools diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/mcp-server/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..b92cf5b --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,131 @@ +# gws MCP Server + +> MCP (Model Context Protocol) server for Google Workspace — wraps the [`gws`](https://github.com/googleworkspace/cli) CLI as MCP tools. + +Any MCP-compatible client (Claude Desktop, Cursor, Zed, Windsurf, etc.) can use this server to interact with Google Workspace APIs: Drive, Gmail, Calendar, Sheets, Docs, Chat, and more. + +## Prerequisites + +1. **Node.js 18+** +2. **`gws` CLI** installed and authenticated: + ```bash + npm install -g @googleworkspace/cli + gws auth login + ``` + +## Quick Start + +### Claude Desktop + +Add to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "google-workspace": { + "command": "npx", + "args": ["-y", "@googleworkspace/mcp-server"] + } + } +} +``` + +### Cursor / Windsurf + +Add to your MCP settings: + +```json +{ + "google-workspace": { + "command": "npx", + "args": ["-y", "@googleworkspace/mcp-server"] + } +} +``` + +### Manual + +```bash +cd mcp-server +npm install +npm run build +node dist/index.js # communicates over stdio +``` + +## Available Tools + +### Per-Service Tools + +Each Google Workspace service has a dedicated tool: + +| Tool | Service | +|------|---------| +| `gws_drive` | Google Drive — files, folders, shared drives | +| `gws_gmail` | Gmail — send, read, manage email | +| `gws_calendar` | Google Calendar — events, calendars | +| `gws_sheets` | Google Sheets — read/write spreadsheets | +| `gws_docs` | Google Docs — read/write documents | +| `gws_slides` | Google Slides — presentations | +| `gws_tasks` | Google Tasks — task lists | +| `gws_people` | Google People — contacts & profiles | +| `gws_chat` | Google Chat — spaces & messages | +| `gws_classroom` | Google Classroom — classes & coursework | +| `gws_forms` | Google Forms — read/write forms | +| `gws_keep` | Google Keep — notes | +| `gws_meet` | Google Meet — conferences | +| `gws_admin_reports` | Admin — audit logs & usage reports | + +Each tool accepts: +- `resource` — API resource (e.g. `files`, `messages`) +- `method` — API method (e.g. `list`, `get`, `create`) +- `params` — JSON query/path parameters +- `body` — JSON request body +- `dry_run` — validate without calling API +- `extra_args` — additional CLI flags + +### Utility Tools + +| Tool | Description | +|------|-------------| +| `gws_run` | Run any arbitrary `gws` command | +| `gws_schema` | Introspect API method schemas | +| `gws_auth_status` | Check authentication status | + +### Resources + +| Resource | Description | +|----------|-------------| +| `gws://services` | List all available services | + +## Examples + +Once connected, you can ask your AI assistant: + +- *"List my 10 most recent Google Drive files"* +- *"Send an email to team@example.com about the Q1 report"* +- *"What meetings do I have tomorrow?"* +- *"Create a new spreadsheet called Budget 2026"* +- *"Show me unread emails from the last hour"* + +The AI will use the appropriate `gws_*` tool with `gws_schema` for discovery. + +## Architecture + +``` +┌─────────────────┐ stdio ┌──────────────┐ exec ┌─────┐ +│ MCP Client │ ◄────────────► │ MCP Server │ ──────────► │ gws │ +│ (Claude, etc.) │ │ (this pkg) │ │ CLI │ +└─────────────────┘ └──────────────┘ └─────┘ + │ + Google Workspace + APIs +``` + +The server wraps the `gws` CLI rather than calling Google APIs directly. This means: +- **Auth is handled by gws** — no extra credential management +- **All gws features work** — pagination, dry-run, Model Armor, etc. +- **Stays in sync** — when gws adds APIs, the `gws_run` tool covers them immediately + +## License + +Apache-2.0 — see [LICENSE](../LICENSE) diff --git a/mcp-server/package.json b/mcp-server/package.json new file mode 100644 index 0000000..e123675 --- /dev/null +++ b/mcp-server/package.json @@ -0,0 +1,29 @@ +{ + "name": "@googleworkspace/mcp-server", + "version": "0.1.0", + "description": "MCP server for Google Workspace — exposes gws CLI as MCP tools", + "license": "Apache-2.0", + "type": "module", + "bin": { + "gws-mcp-server": "./dist/index.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx src/index.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.0.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=18" + } +} diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts new file mode 100644 index 0000000..c1412e4 --- /dev/null +++ b/mcp-server/src/index.ts @@ -0,0 +1,275 @@ +#!/usr/bin/env node + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * MCP Server for Google Workspace CLI (gws) + * + * Wraps the `gws` CLI as MCP tools, enabling any MCP-compatible client + * (Claude Desktop, Cursor, Zed, etc.) to interact with Google Workspace APIs. + * + * Tools are generated dynamically from the gws service registry. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { which } from "./which.js"; + +const execFileAsync = promisify(execFile); + +// --------------------------------------------------------------------------- +// Service registry — mirrors src/services.rs +// --------------------------------------------------------------------------- + +interface ServiceDef { + name: string; + description: string; +} + +const SERVICES: ServiceDef[] = [ + { name: "drive", description: "Manage files, folders, and shared drives" }, + { name: "sheets", description: "Read and write spreadsheets" }, + { name: "gmail", description: "Send, read, and manage email" }, + { name: "calendar", description: "Manage calendars and events" }, + { name: "docs", description: "Read and write Google Docs" }, + { name: "slides", description: "Read and write presentations" }, + { name: "tasks", description: "Manage task lists and tasks" }, + { name: "people", description: "Manage contacts and profiles" }, + { name: "chat", description: "Manage Chat spaces and messages" }, + { name: "classroom", description: "Manage classes, rosters, and coursework" }, + { name: "forms", description: "Read and write Google Forms" }, + { name: "keep", description: "Manage Google Keep notes" }, + { name: "meet", description: "Manage Google Meet conferences" }, + { name: "admin-reports", description: "Audit logs and usage reports" }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Maximum output size (bytes) to prevent unbounded responses. */ +const MAX_OUTPUT = 100_000; + +function truncate(text: string, max = MAX_OUTPUT): string { + if (text.length <= max) return text; + return text.slice(0, max) + `\n... (truncated at ${max} bytes)`; +} + +/** Run a gws command and return { stdout, stderr, exitCode }. */ +async function runGws( + args: string[], + timeoutMs = 30_000, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const bin = await which("gws"); + if (!bin) { + return { + stdout: "", + stderr: + "gws binary not found on $PATH. Install: npm i -g @googleworkspace/cli", + exitCode: 127, + }; + } + + try { + const { stdout, stderr } = await execFileAsync(bin, args, { + timeout: timeoutMs, + maxBuffer: MAX_OUTPUT * 2, + env: { ...process.env }, + }); + return { stdout: truncate(stdout), stderr: truncate(stderr), exitCode: 0 }; + } catch (err: unknown) { + const e = err as { + stdout?: string; + stderr?: string; + code?: number | string; + }; + return { + stdout: truncate(e.stdout ?? ""), + stderr: truncate(e.stderr ?? String(err)), + exitCode: typeof e.code === "number" ? e.code : 1, + }; + } +} + +// --------------------------------------------------------------------------- +// MCP Server +// --------------------------------------------------------------------------- + +const server = new McpServer({ + name: "gws-mcp-server", + version: "0.1.0", +}); + +// ── Generic gws_run tool ────────────────────────────────────────────────── +// Accepts arbitrary gws arguments. This is the escape hatch for any command +// not covered by the per-service tools. +server.tool( + "gws_run", + "Run any gws CLI command. Pass the full argument list (e.g. ['drive', 'files', 'list', '--params', '{\"pageSize\":5}']). Use this for advanced commands, auth management, or services without a dedicated tool.", + { + args: z + .array(z.string()) + .describe("Arguments to pass to gws (e.g. ['drive', 'files', 'list'])"), + timeout_ms: z + .number() + .optional() + .describe("Timeout in milliseconds (default 30000)"), + }, + async ({ args, timeout_ms }) => { + const result = await runGws(args, timeout_ms); + const text = [ + result.stdout, + result.stderr ? `\n--- stderr ---\n${result.stderr}` : "", + `\n--- exit code: ${result.exitCode} ---`, + ].join(""); + return { + content: [{ type: "text" as const, text }], + isError: result.exitCode !== 0, + }; + }, +); + +// ── Per-service tools ───────────────────────────────────────────────────── +// Each service gets a tool like gws_drive, gws_gmail, etc. +for (const svc of SERVICES) { + const toolName = `gws_${svc.name.replace("-", "_")}`; + server.tool( + toolName, + `Google Workspace — ${svc.description}. Runs: gws ${svc.name} [flags]. Use gws_schema to discover available resources and methods.`, + { + resource: z.string().describe("API resource (e.g. 'files', 'messages', 'events')"), + method: z.string().describe("API method (e.g. 'list', 'get', 'create', 'delete')"), + params: z + .string() + .optional() + .describe("JSON string of query/path parameters (passed to --params)"), + body: z + .string() + .optional() + .describe("JSON string of request body (passed to --json)"), + extra_args: z + .array(z.string()) + .optional() + .describe("Additional CLI flags (e.g. ['--page-all', '--format', 'csv'])"), + dry_run: z + .boolean() + .optional() + .describe("If true, validate locally without calling the API"), + timeout_ms: z + .number() + .optional() + .describe("Timeout in milliseconds (default 30000)"), + }, + async ({ resource, method, params, body, extra_args, dry_run, timeout_ms }) => { + const args: string[] = [svc.name, resource, method]; + if (params) args.push("--params", params); + if (body) args.push("--json", body); + if (dry_run) args.push("--dry-run"); + if (extra_args) args.push(...extra_args); + + const result = await runGws(args, timeout_ms); + const text = [ + result.stdout, + result.stderr ? `\n--- stderr ---\n${result.stderr}` : "", + `\n--- exit code: ${result.exitCode} ---`, + ].join(""); + return { + content: [{ type: "text" as const, text }], + isError: result.exitCode !== 0, + }; + }, + ); +} + +// ── Schema introspection tool ───────────────────────────────────────────── +server.tool( + "gws_schema", + "Introspect a Google Workspace API method schema. Returns parameters, request/response shapes. Path format: service.resource.method (e.g. 'drive.files.list').", + { + path: z + .string() + .describe("Dotted schema path: service.resource.method (e.g. 'drive.files.list', 'gmail.users.messages.get')"), + resolve_refs: z + .boolean() + .optional() + .describe("If true, inline $ref schemas for a self-contained view"), + }, + async ({ path, resolve_refs }) => { + const args = ["schema", path]; + if (resolve_refs) args.push("--resolve-refs"); + const result = await runGws(args); + const text = [ + result.stdout, + result.stderr ? `\n--- stderr ---\n${result.stderr}` : "", + ].join(""); + return { + content: [{ type: "text" as const, text }], + isError: result.exitCode !== 0, + }; + }, +); + +// ── Auth status tool ────────────────────────────────────────────────────── +server.tool( + "gws_auth_status", + "Check gws authentication status. Returns current login state and available scopes.", + {}, + async () => { + const result = await runGws(["auth", "status"]); + const text = [result.stdout, result.stderr].filter(Boolean).join("\n"); + return { + content: [{ type: "text" as const, text }], + isError: result.exitCode !== 0, + }; + }, +); + +// ── Services list resource ──────────────────────────────────────────────── +server.resource( + "services", + "gws://services", + async (uri) => { + const text = SERVICES.map( + (s) => `- **${s.name}**: ${s.description}`, + ).join("\n"); + return { + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: `# Available Google Workspace Services\n\n${text}\n\nUse \`gws_schema\` to discover resources and methods for each service.`, + }, + ], + }; + }, +); + +// --------------------------------------------------------------------------- +// Start +// --------------------------------------------------------------------------- + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("gws MCP server running on stdio"); +} + +main().catch((err) => { + console.error("Fatal:", err); + process.exit(1); +}); diff --git a/mcp-server/src/which.ts b/mcp-server/src/which.ts new file mode 100644 index 0000000..1520d9b --- /dev/null +++ b/mcp-server/src/which.ts @@ -0,0 +1,28 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 + +import { access, constants } from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Locate an executable on $PATH (pure-Node, no external deps). + * Returns the full path or null if not found. + */ +export async function which(name: string): Promise { + const dirs = (process.env.PATH ?? "").split(":"); + for (const dir of dirs) { + const full = join(dir, name); + try { + await access(full, constants.X_OK); + return full; + } catch { + // not here, try next + } + } + return null; +} diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json new file mode 100644 index 0000000..9af0df2 --- /dev/null +++ b/mcp-server/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "node16", + "moduleResolution": "node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*"] +}