diff --git a/cartridges/google-drive-mcp/README.adoc b/cartridges/google-drive-mcp/README.adoc new file mode 100644 index 00000000..a74d405f --- /dev/null +++ b/cartridges/google-drive-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += google-drive-mcp +:toc: preamble + +Google Drive cartridge for BoJ — Drive API v3 over the ADR-0006 five-symbol +cartridge ABI. + +== Why this cartridge + +Designed to *supersede the hosted claude.ai Google Drive connector* (8 +read-mostly tools) with a fuller, safer surface — 18 tools: + +|=== +| Capability | Hosted connector | this cartridge + +| Search | basic | full `files.list` query language, shared drives, `orderBy`, pagination +| Read content | text | export-aware (Docs→Markdown/plain, Sheets→CSV), size-capped +| Export formats | — | any export MIME (pdf, docx, md, csv…), base64 for binary +| Metadata / recents / permissions | ✓ | ✓ (richer fields) +| Folder listing | — | ✓ +| Copy / create | ✓ | ✓ (+ inline content upload, folders, native Google types) +| Update content | — | ✓ +| Move / rename | — | ✓ +| Delete | — | *reversible* trash/restore ONLY — permanent delete deliberately absent +| Sharing (grant) | — | ✓ (reader/commenter/writer; no owner transfer) +| Revisions | — | ✓ +| Storage quota | — | ✓ +| Shared-drive enumeration | — | ✓ +|=== + +== Safety posture + +* *No permanent delete.* `files.delete` is not implemented; `gdrive_trash` is + always recoverable via `gdrive_restore` or the Drive bin. Fail-safe by design. +* *No owner transfer.* `gdrive_share` grants reader/commenter/writer only. +* Notification emails are opt-in (`send_notification`, default false). +* Size caps on content reads (default 1 MiB, caller-adjustable). + +== Auth + +OAuth2 Bearer token in `GOOGLE_DRIVE_ACCESS_TOKEN` (vault-mcp provides +zero-knowledge credential proxying in production). Scopes: +`drive.readonly` for the read tools, `drive` for write/share. + +== Layout + +* `cartridge.json` — contract (18 tool schemas) +* `mod.js` — implementation (Deno/Node; `handleTool`) +* `abi/GoogleDriveMcp/SafeRegistry.idr` — Idris2 session state machine +* `ffi/` — Zig five-symbol ABI (`boj_cartridge_*`) +* `adapter/` — Zig three-protocol adapter (REST :9301, gRPC :9302, GraphQL :9303) +* `panels/manifest.json` — PanLL panel manifest +* `tests/integration_test.sh`, `benchmarks/quick-bench.sh` + +== Status + +`catalogued` / `available: false` — implementation complete, not yet wired to a +live backend deployment. The registry stays honest until it is. diff --git a/cartridges/google-drive-mcp/abi/GoogleDriveMcp/SafeRegistry.idr b/cartridges/google-drive-mcp/abi/GoogleDriveMcp/SafeRegistry.idr new file mode 100644 index 00000000..d099bd2c --- /dev/null +++ b/cartridges/google-drive-mcp/abi/GoogleDriveMcp/SafeRegistry.idr @@ -0,0 +1,248 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) Jonathan D.A. Jewell +-- +-- GoogleDriveMcp.SafeRegistry — Type-safe ABI for google-drive-mcp cartridge. +-- +-- Dependent-type state machine governing Google Drive API v3 access. +-- Encodes mandatory OAuth2 auth and the 18 Drive operations (search, +-- export-aware read, metadata, folder/recents listing, permissions, sharing, +-- copy, create, update, move, rename, REVERSIBLE trash/restore, revisions, +-- storage quota, shared-drive enumeration) as compile-time invariants. +-- +-- Safety invariant encoded here: there is NO permanent-delete action. The most +-- destructive operation is `Trash`, which is reversible via `Restore`. This is +-- a type-level guarantee, not a convention — `actionIsDestructive` is total and +-- has no `True` case. +-- REST API: https://www.googleapis.com/drive/v3 +-- No unsafe escape hatches. + +module GoogleDriveMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for Google Drive MCP operations. +||| Disconnected: no OAuth2 token configured. +||| Connected: authenticated and ready for API calls. +||| RateLimited: rate limit hit; must wait before retrying. +||| Error: unrecoverable error (expired token, permission denied). +public export +data SessionState + = Disconnected + | Connected + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Google Drive requires OAuth2 auth — no anonymous access. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Connect : ValidTransition Disconnected Connected + Disconnect : ValidTransition Connected Disconnected + Throttle : ValidTransition Connected RateLimited + Unthrottle : ValidTransition RateLimited Connected + ConnectError : ValidTransition Connected Error + DisconnError : ValidTransition Disconnected Error + RecoverConnect : ValidTransition Error Connected + RecoverDisconn : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Disconnected = 0 +sessionStateToInt Connected = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Disconnected +intToSessionState 1 = Just Connected +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +google_drive_mcp_can_transition : Int -> Int -> Int +google_drive_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just Connected, Just RateLimited) => 1 + (Just RateLimited, Just Connected) => 1 + (Just Connected, Just Error) => 1 + (Just Disconnected, Just Error) => 1 + (Just Error, Just Connected) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Google Drive actions (integer codes match google_drive_mcp_ffi.zig exactly) +-- --------------------------------------------------------------------------- + +||| Actions available through the Google Drive MCP cartridge. +public export +data GoogleDriveAction + = Search + | ReadContent + | ExportFile + | GetMetadata + | ListRecent + | ListFolder + | GetPermissions + | Share + | Copy + | CreateFile + | UpdateContent + | Move + | Rename + | Trash + | Restore + | ListRevisions + | StorageQuota + | ListSharedDrives + +||| Every Drive action requires an authenticated (Connected) session. +export +actionRequiresAuth : GoogleDriveAction -> Bool +actionRequiresAuth _ = True + +||| Whether an action mutates Drive state (create/modify/move/share/trash). +||| Read/list/export/metadata/quota/revisions are non-mutating. +export +actionIsMutating : GoogleDriveAction -> Bool +actionIsMutating Share = True +actionIsMutating Copy = True +actionIsMutating CreateFile = True +actionIsMutating UpdateContent = True +actionIsMutating Move = True +actionIsMutating Rename = True +actionIsMutating Trash = True +actionIsMutating Restore = True +actionIsMutating _ = False + +||| SAFETY INVARIANT: no action is permanently destructive. `Trash` is +||| reversible via `Restore`; permanent `files.delete` is deliberately not part +||| of this ABI. Total function with no `True` clause == compile-time proof that +||| the cartridge cannot irreversibly destroy data. +export +actionIsDestructive : GoogleDriveAction -> Bool +actionIsDestructive _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : GoogleDriveAction -> Int +actionToInt Search = 0 +actionToInt ReadContent = 1 +actionToInt ExportFile = 2 +actionToInt GetMetadata = 3 +actionToInt ListRecent = 4 +actionToInt ListFolder = 5 +actionToInt GetPermissions = 6 +actionToInt Share = 7 +actionToInt Copy = 8 +actionToInt CreateFile = 9 +actionToInt UpdateContent = 10 +actionToInt Move = 11 +actionToInt Rename = 12 +actionToInt Trash = 13 +actionToInt Restore = 14 +actionToInt ListRevisions = 15 +actionToInt StorageQuota = 16 +actionToInt ListSharedDrives = 17 + +||| Decode integer to Google Drive action. +export +intToAction : Int -> Maybe GoogleDriveAction +intToAction 0 = Just Search +intToAction 1 = Just ReadContent +intToAction 2 = Just ExportFile +intToAction 3 = Just GetMetadata +intToAction 4 = Just ListRecent +intToAction 5 = Just ListFolder +intToAction 6 = Just GetPermissions +intToAction 7 = Just Share +intToAction 8 = Just Copy +intToAction 9 = Just CreateFile +intToAction 10 = Just UpdateContent +intToAction 11 = Just Move +intToAction 12 = Just Rename +intToAction 13 = Just Trash +intToAction 14 = Just Restore +intToAction 15 = Just ListRevisions +intToAction 16 = Just StorageQuota +intToAction 17 = Just ListSharedDrives +intToAction _ = Nothing + +||| Round-trip proof: decoding an encoded action recovers it. Machine-checked +||| for all 18 constructors, guaranteeing the FFI integer map is injective. +export +actionRoundTrip : (a : GoogleDriveAction) -> intToAction (actionToInt a) = Just a +actionRoundTrip Search = Refl +actionRoundTrip ReadContent = Refl +actionRoundTrip ExportFile = Refl +actionRoundTrip GetMetadata = Refl +actionRoundTrip ListRecent = Refl +actionRoundTrip ListFolder = Refl +actionRoundTrip GetPermissions = Refl +actionRoundTrip Share = Refl +actionRoundTrip Copy = Refl +actionRoundTrip CreateFile = Refl +actionRoundTrip UpdateContent = Refl +actionRoundTrip Move = Refl +actionRoundTrip Rename = Refl +actionRoundTrip Trash = Refl +actionRoundTrip Restore = Refl +actionRoundTrip ListRevisions = Refl +actionRoundTrip StorageQuota = Refl +actionRoundTrip ListSharedDrives = Refl + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge (one per action). +public export +data McpTool + = ToolSearch + | ToolReadContent + | ToolExport + | ToolGetMetadata + | ToolListRecent + | ToolListFolder + | ToolGetPermissions + | ToolShare + | ToolCopy + | ToolCreateFile + | ToolUpdateContent + | ToolMove + | ToolRename + | ToolTrash + | ToolRestore + | ToolListRevisions + | ToolStorageQuota + | ToolListSharedDrives + +||| Every tool requires a connected session (OAuth2-gated). +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = True + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 18 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 18 diff --git a/cartridges/google-drive-mcp/abi/README.adoc b/cartridges/google-drive-mcp/abi/README.adoc new file mode 100644 index 00000000..badde511 --- /dev/null +++ b/cartridges/google-drive-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) Jonathan D.A. Jewell += google-drive-mcp / abi — Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `google-drive-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~174 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` — consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` — cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/google-drive-mcp/adapter/README.adoc b/cartridges/google-drive-mcp/adapter/README.adoc new file mode 100644 index 00000000..2b01113f --- /dev/null +++ b/cartridges/google-drive-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) Jonathan D.A. Jewell += google-sheets-mcp / adapter — protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph — depends on the sibling `ffi` target. +| `google_sheets_adapter.zig` | Protocol dispatch (204 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built — zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** — all state lives behind the FFI, never in the adapter. +* **Response passthrough** — whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer — protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `google_sheets_adapter.zig` — dispatch function and per-protocol routers. +. `../cartridge.json` — the tool manifest the dispatcher mirrors. diff --git a/cartridges/google-drive-mcp/adapter/build.zig b/cartridges/google-drive-mcp/adapter/build.zig new file mode 100644 index 00000000..70e8a465 --- /dev/null +++ b/cartridges/google-drive-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// google-drive-mcp/adapter/build.zig +// Zig 0.15 module-based build (aligned with ffi/build.zig). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/google_drive_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter_mod = b.createModule(.{ + .root_source_file = b.path("google_drive_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter_mod.addImport("google_drive_mcp_ffi", ffi_mod); + + const adapter = b.addExecutable(.{ + .name = "google_drive_adapter", + .root_module = adapter_mod, + }); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the google-drive-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ .root_module = adapter_mod }); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run google-drive-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/google-drive-mcp/adapter/google_drive_adapter.zig b/cartridges/google-drive-mcp/adapter/google_drive_adapter.zig new file mode 100644 index 00000000..bfb93244 --- /dev/null +++ b/cartridges/google-drive-mcp/adapter/google_drive_adapter.zig @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// google-drive-mcp/adapter/google_drive_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned google_drive_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (google_drive_mcp_ffi.zig) to three network protocols: +// REST :9301 POST /tools/ +// gRPC-compat :9302 /GoogleDriveMcpService/ +// GraphQL :9303 POST /graphql { query: "..." } +// +// Google Drive API v3: search, content, permissions, mutations (reversible trash only) +// Tools: +// gdrive_search +// gdrive_read_content +// gdrive_export +// gdrive_get_metadata +// gdrive_list_recent +// gdrive_list_folder +// gdrive_get_permissions +// gdrive_share +// gdrive_copy +// gdrive_create_file +// gdrive_update_content +// gdrive_move +// gdrive_rename +// gdrive_trash +// gdrive_restore +// gdrive_list_revisions +// gdrive_storage_quota +// gdrive_list_shared_drives + +const std = @import("std"); +const ffi = @import("google_drive_mcp_ffi"); + +const REST_PORT: u16 = 9301; +const GRPC_PORT: u16 = 9302; +const GQL_PORT: u16 = 9303; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \\{{"success":true,"message":"{s}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \\{{"success":false,"error":"{s}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \\{{"success":true,"state":"ready","service":"google-drive-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "gdrive_search")) return .{ .status = 200, .body = okJson(resp, "gdrive_search forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_read_content")) return .{ .status = 200, .body = okJson(resp, "gdrive_read_content forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_export")) return .{ .status = 200, .body = okJson(resp, "gdrive_export forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_get_metadata")) return .{ .status = 200, .body = okJson(resp, "gdrive_get_metadata forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_list_recent")) return .{ .status = 200, .body = okJson(resp, "gdrive_list_recent forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_list_folder")) return .{ .status = 200, .body = okJson(resp, "gdrive_list_folder forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_get_permissions")) return .{ .status = 200, .body = okJson(resp, "gdrive_get_permissions forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_share")) return .{ .status = 200, .body = okJson(resp, "gdrive_share forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_copy")) return .{ .status = 200, .body = okJson(resp, "gdrive_copy forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_create_file")) return .{ .status = 200, .body = okJson(resp, "gdrive_create_file forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_update_content")) return .{ .status = 200, .body = okJson(resp, "gdrive_update_content forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_move")) return .{ .status = 200, .body = okJson(resp, "gdrive_move forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_rename")) return .{ .status = 200, .body = okJson(resp, "gdrive_rename forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_trash")) return .{ .status = 200, .body = okJson(resp, "gdrive_trash forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_restore")) return .{ .status = 200, .body = okJson(resp, "gdrive_restore forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_list_revisions")) return .{ .status = 200, .body = okJson(resp, "gdrive_list_revisions forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_storage_quota")) return .{ .status = 200, .body = okJson(resp, "gdrive_storage_quota forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdrive_list_shared_drives")) return .{ .status = 200, .body = okJson(resp, "gdrive_list_shared_drives forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/GoogleDriveMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "GsheetsGetSpreadsheet")) break :blk "gdrive_get_spreadsheet"; + if (std.mem.eql(u8, method, "GsheetsReadRange")) break :blk "gdrive_read_range"; + if (std.mem.eql(u8, method, "GsheetsListSheets")) break :blk "gdrive_list_sheets"; + if (std.mem.eql(u8, method, "GsheetsGetNamedRanges")) break :blk "gdrive_get_named_ranges"; + if (std.mem.eql(u8, method, "GsheetsWriteRange")) break :blk "gdrive_write_range"; + if (std.mem.eql(u8, method, "GsheetsAppendRows")) break :blk "gdrive_append_rows"; + if (std.mem.eql(u8, method, "GsheetsCreateSheet")) break :blk "gdrive_create_sheet"; + if (std.mem.eql(u8, method, "GsheetsBatchRead")) break :blk "gdrive_batch_read"; + if (std.mem.eql(u8, method, "GsheetsGetConditionalFormats")) break :blk "gdrive_get_conditional_formats"; + if (std.mem.eql(u8, method, "GsheetsGetPivotTables")) break :blk "gdrive_get_pivot_tables"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "get_spreadsheet") != null) return dispatch("gdrive_get_spreadsheet", body, resp); + if (std.mem.indexOf(u8, body, "read_range") != null) return dispatch("gdrive_read_range", body, resp); + if (std.mem.indexOf(u8, body, "list_sheets") != null) return dispatch("gdrive_list_sheets", body, resp); + if (std.mem.indexOf(u8, body, "get_named_ranges") != null) return dispatch("gdrive_get_named_ranges", body, resp); + if (std.mem.indexOf(u8, body, "write_range") != null) return dispatch("gdrive_write_range", body, resp); + if (std.mem.indexOf(u8, body, "append_rows") != null) return dispatch("gdrive_append_rows", body, resp); + if (std.mem.indexOf(u8, body, "create_sheet") != null) return dispatch("gdrive_create_sheet", body, resp); + if (std.mem.indexOf(u8, body, "batch_read") != null) return dispatch("gdrive_batch_read", body, resp); + if (std.mem.indexOf(u8, body, "get_conditional_formats") != null) return dispatch("gdrive_get_conditional_formats", body, resp); + if (std.mem.indexOf(u8, body, "get_pivot_tables") != null) return dispatch("gdrive_get_pivot_tables", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.google_drive_mcp_reset(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/google-drive-mcp/benchmarks/quick-bench.sh b/cartridges/google-drive-mcp/benchmarks/quick-bench.sh new file mode 100755 index 00000000..c73523b3 --- /dev/null +++ b/cartridges/google-drive-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for google-drive-mcp cartridge. +set -euo pipefail + +echo "=== google-drive-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session connect/disconnect cycle (1000 iterations):" +time for i in $(seq 1 1000); do + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/google-drive-mcp/cartridge.json b/cartridges/google-drive-mcp/cartridge.json new file mode 100644 index 00000000..c00a8cfd --- /dev/null +++ b/cartridges/google-drive-mcp/cartridge.json @@ -0,0 +1,259 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "google-drive-mcp", + "available": false, + "status": "catalogued", + "version": "0.1.0", + "description": "Google Drive cartridge -- full-query search (incl. shared drives), export-aware content reading, folder listing, metadata, permissions listing and granting, copy, create with content, update content, move, rename, reversible trash/restore (no permanent delete by design), revision history, format export, storage quota, and shared-drive enumeration via the Google Drive API v3", + "domain": "Productivity", + "category": "domain", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "oauth2", + "env_var": "GOOGLE_DRIVE_ACCESS_TOKEN", + "credential_source": "vault-mcp", + "scopes": [ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive" + ] + }, + "api": { + "base_url": "https://www.googleapis.com/drive/v3", + "content_type": "application/json" + }, + "safety": { + "no_permanent_delete": "This cartridge deliberately exposes only reversible trash/restore. files.delete (permanent, unrecoverable) is not implemented; recovery is always possible via gdrive_restore or the Drive UI bin." + }, + "tools": [ + { + "name": "gdrive_search", + "description": "Search Drive with the full files.list query language (q=), e.g. \"name contains 'report' and mimeType='application/pdf' and modifiedTime > '2026-01-01'\". Supports shared drives, ordering, and pagination", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Drive query language expression (https://developers.google.com/drive/api/guides/search-files). Empty string lists everything visible" }, + "page_size": { "type": "integer", "description": "Results per page, 1-1000 (default 25)" }, + "page_token": { "type": "string", "description": "Token from a previous page's nextPageToken" }, + "order_by": { "type": "string", "description": "Comma list, e.g. 'modifiedTime desc', 'name', 'quotaBytesUsed desc'" }, + "include_shared_drives": { "type": "boolean", "description": "Also search shared drives (default true)" }, + "drive_id": { "type": "string", "description": "Restrict to one shared drive" } + }, + "required": ["query"] + } + }, + { + "name": "gdrive_read_content", + "description": "Read a file's content. Google-native files are exported (Docs->text/markdown, Sheets->CSV, Slides->plain text) automatically; regular files are downloaded as text. Size-capped", + "inputSchema": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "Drive file ID" }, + "prefer_markdown": { "type": "boolean", "description": "Export Google Docs as Markdown instead of plain text (default true)" }, + "max_bytes": { "type": "integer", "description": "Refuse to return more than this many bytes (default 1048576)" } + }, + "required": ["file_id"] + } + }, + { + "name": "gdrive_export", + "description": "Export a Google-native file to an explicit MIME type (e.g. application/pdf, text/markdown, text/csv, docx). Returns base64 for binary types", + "inputSchema": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "Drive file ID of a Google-native file" }, + "mime_type": { "type": "string", "description": "Target MIME type" } + }, + "required": ["file_id", "mime_type"] + } + }, + { + "name": "gdrive_get_metadata", + "description": "Full metadata for one file: name, mimeType, size, owners, parents, timestamps, sharing state, webViewLink, capabilities", + "inputSchema": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "Drive file ID" } + }, + "required": ["file_id"] + } + }, + { + "name": "gdrive_list_recent", + "description": "Most recently modified files visible to the user", + "inputSchema": { + "type": "object", + "properties": { + "page_size": { "type": "integer", "description": "How many (default 20, max 100)" }, + "include_shared_drives": { "type": "boolean", "description": "Include shared-drive files (default true)" } + }, + "required": [] + } + }, + { + "name": "gdrive_list_folder", + "description": "List the direct children of a folder (files and subfolders), with pagination", + "inputSchema": { + "type": "object", + "properties": { + "folder_id": { "type": "string", "description": "Folder ID ('root' for My Drive root)" }, + "page_size": { "type": "integer", "description": "Results per page (default 50)" }, + "page_token": { "type": "string", "description": "Token from a previous page" }, + "order_by": { "type": "string", "description": "e.g. 'folder,name' (default) or 'modifiedTime desc'" } + }, + "required": ["folder_id"] + } + }, + { + "name": "gdrive_get_permissions", + "description": "List who has access to a file and at what role", + "inputSchema": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "Drive file ID" } + }, + "required": ["file_id"] + } + }, + { + "name": "gdrive_share", + "description": "Grant a user, group, domain, or anyone a role on a file. Sends notification email only if asked", + "inputSchema": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "Drive file ID" }, + "role": { "type": "string", "enum": ["reader", "commenter", "writer"], "description": "Role to grant (owner transfers are deliberately not supported)" }, + "grantee_type": { "type": "string", "enum": ["user", "group", "domain", "anyone"], "description": "Who" }, + "email": { "type": "string", "description": "Email for user/group grants" }, + "domain": { "type": "string", "description": "Domain for domain grants" }, + "send_notification": { "type": "boolean", "description": "Send notification email (default false)" } + }, + "required": ["file_id", "role", "grantee_type"] + } + }, + { + "name": "gdrive_copy", + "description": "Copy a file, optionally into a different folder and/or with a new name", + "inputSchema": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "Source file ID" }, + "name": { "type": "string", "description": "Name for the copy (default: 'Copy of ')" }, + "parent_folder_id": { "type": "string", "description": "Destination folder ID (default: same as source)" } + }, + "required": ["file_id"] + } + }, + { + "name": "gdrive_create_file", + "description": "Create a file or folder. For files, optional inline text content is uploaded in the same call (multipart)", + "inputSchema": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "File or folder name" }, + "mime_type": { "type": "string", "description": "MIME type; use application/vnd.google-apps.folder for a folder, application/vnd.google-apps.document to create a Google Doc from the content (default text/plain)" }, + "parent_folder_id": { "type": "string", "description": "Parent folder ID (default 'root')" }, + "content": { "type": "string", "description": "Inline text content for files (omit for folders)" } + }, + "required": ["name"] + } + }, + { + "name": "gdrive_update_content", + "description": "Replace a file's content with new text (media upload). Metadata unchanged", + "inputSchema": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "Drive file ID" }, + "content": { "type": "string", "description": "New full text content" }, + "mime_type": { "type": "string", "description": "Content MIME type (default text/plain)" } + }, + "required": ["file_id", "content"] + } + }, + { + "name": "gdrive_move", + "description": "Move a file to a different folder (removes from current parents, adds the new one)", + "inputSchema": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "Drive file ID" }, + "destination_folder_id": { "type": "string", "description": "New parent folder ID" } + }, + "required": ["file_id", "destination_folder_id"] + } + }, + { + "name": "gdrive_rename", + "description": "Rename a file or folder", + "inputSchema": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "Drive file ID" }, + "new_name": { "type": "string", "description": "New name" } + }, + "required": ["file_id", "new_name"] + } + }, + { + "name": "gdrive_trash", + "description": "Move a file to the bin (REVERSIBLE -- restore with gdrive_restore). Permanent deletion is deliberately not offered by this cartridge", + "inputSchema": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "Drive file ID" } + }, + "required": ["file_id"] + } + }, + { + "name": "gdrive_restore", + "description": "Restore a file from the bin", + "inputSchema": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "Drive file ID" } + }, + "required": ["file_id"] + } + }, + { + "name": "gdrive_list_revisions", + "description": "Revision history for a file: who modified it and when, with export links where available", + "inputSchema": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "Drive file ID" }, + "page_size": { "type": "integer", "description": "Revisions per page (default 20)" } + }, + "required": ["file_id"] + } + }, + { + "name": "gdrive_storage_quota", + "description": "The account's storage quota: limit, total usage, usage in Drive vs Trash", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "gdrive_list_shared_drives", + "description": "Enumerate shared drives the user can access", + "inputSchema": { + "type": "object", + "properties": { + "page_size": { "type": "integer", "description": "Drives per page (default 20)" }, + "page_token": { "type": "string", "description": "Token from a previous page" } + }, + "required": [] + } + } + ] +} diff --git a/cartridges/google-drive-mcp/ffi/README.adoc b/cartridges/google-drive-mcp/ffi/README.adoc new file mode 100644 index 00000000..e554d8c7 --- /dev/null +++ b/cartridges/google-drive-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) Jonathan D.A. Jewell += google-sheets-mcp / ffi — Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph — produces `zig-out/lib/libgoogle-sheets.so`. +| `google_sheets_mcp_ffi.zig` | C-ABI exports (15 exports, 5 inline tests, 333 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** — every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `google_sheets_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `google_sheets_mcp_ffi.zig` — C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/google-drive-mcp/ffi/build.zig b/cartridges/google-drive-mcp/ffi/build.zig new file mode 100644 index 00000000..9dece9cb --- /dev/null +++ b/cartridges/google-drive-mcp/ffi/build.zig @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("google_drive_mcp", .{ + .root_source_file = b.path("google_drive_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "google_drive_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/google-drive-mcp/ffi/cartridge_shim.zig b/cartridges/google-drive-mcp/ffi/cartridge_shim.zig new file mode 100644 index 00000000..473c6009 --- /dev/null +++ b/cartridges/google-drive-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// cartridge_shim.zig — Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short — typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 §Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body — the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` — the caller is then expected to re-allocate +/// and retry, per ADR-0006 §Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/google-drive-mcp/ffi/google_drive_mcp_ffi.zig b/cartridges/google-drive-mcp/ffi/google_drive_mcp_ffi.zig new file mode 100644 index 00000000..caebe579 --- /dev/null +++ b/cartridges/google-drive-mcp/ffi/google_drive_mcp_ffi.zig @@ -0,0 +1,475 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// google_drive_mcp_ffi.zig — C-ABI FFI implementation for google-drive-mcp cartridge. +// +// Implements the state machine defined in GoogleDriveMcp.SafeRegistry (Idris2 ABI). +// State machine: Disconnected | Connected | RateLimited | Error +// Auth: Required OAuth2 Bearer token — Google Drive API is always gated. +// REST API: https://www.googleapis.com/drive/v3 +// Actions: Search, ReadContent, Export, GetMetadata, ListRecent, ListFolder, +// GetPermissions, Share, Copy, CreateFile, UpdateContent, Move, +// Rename, Trash, Restore, ListRevisions, StorageQuota, +// ListSharedDrives (trash is reversible; no permanent delete) +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +pub const SessionState = enum(c_int) { + disconnected = 0, + connected = 1, + rate_limited = 2, + err = 3, +}; + +pub const GoogleDriveAction = enum(c_int) { + search = 0, + read_content = 1, + export_file = 2, + get_metadata = 3, + list_recent = 4, + list_folder = 5, + get_permissions = 6, + share = 7, + copy = 8, + create_file = 9, + update_content = 10, + move = 11, + rename = 12, + trash = 13, + restore = 14, + list_revisions = 15, + storage_quota = 16, + list_shared_drives = 17, +}; + +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .disconnected => to == .connected or to == .err, + .connected => to == .disconnected or to == .rate_limited or to == .err, + .rate_limited => to == .connected, + .err => to == .connected or to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .disconnected, + api_call_count: u64 = 0, + last_action: c_int = -1, + content_reads: u32 = 0, + mutations: u32 = 0, + search_queries: u32 = 0, + admin_queries: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports — state machine +// --------------------------------------------------------------------------- + +pub export fn google_drive_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +pub export fn google_drive_mcp_connect(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .connected; + slot.api_call_count = 0; + slot.last_action = -1; + slot.content_reads = 0; + slot.mutations = 0; + slot.search_queries = 0; + slot.admin_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +pub export fn google_drive_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +pub export fn google_drive_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +pub export fn google_drive_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +pub export fn google_drive_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + sessions[idx].state = .connected; + return 0; +} + +pub export fn google_drive_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports — action recording and metrics +// --------------------------------------------------------------------------- + +pub export fn google_drive_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(GoogleDriveAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .connected) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .read_content, .export_file, .get_metadata, .list_revisions => sessions[idx].content_reads += 1, + .share, .copy, .create_file, .update_content, .move, .rename, .trash, .restore => sessions[idx].mutations += 1, + .search, .list_recent, .list_folder => sessions[idx].search_queries += 1, + .get_permissions, .storage_quota, .list_shared_drives => sessions[idx].admin_queries += 1, + } + + return 0; +} + +pub export fn google_drive_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +pub export fn google_drive_mcp_content_read_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.content_reads); +} + +pub export fn google_drive_mcp_mutation_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.mutations); +} + +pub export fn google_drive_mcp_search_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.search_queries); +} + +pub export fn google_drive_mcp_admin_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.admin_queries); +} + +pub export fn google_drive_mcp_action_count() c_int { + return 10; +} + +pub export fn google_drive_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "google-drive-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "gdrive_search")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_read_content")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_export")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_get_metadata")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_list_recent")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_list_folder")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_get_permissions")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_list_revisions")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_storage_quota")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_list_shared_drives")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_share")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_copy")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_create_file")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_update_content")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_move")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_rename")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_trash")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdrive_restore")) + "{\"result\":{\"status\":\"stub\"}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "connected session lifecycle" { + google_drive_mcp_reset(); + + const slot = google_drive_mcp_connect(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), google_drive_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), google_drive_mcp_record_call(slot, 1)); + try std.testing.expectEqual(@as(c_int, 1), google_drive_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_drive_mcp_content_read_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), google_drive_mcp_disconnect(slot)); +} + +test "requires connected state for actions" { + google_drive_mcp_reset(); + + const slot = google_drive_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), google_drive_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, -2), google_drive_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), google_drive_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 0), google_drive_mcp_record_call(slot, 0)); +} + +test "category counting" { + google_drive_mcp_reset(); + + const slot = google_drive_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), google_drive_mcp_record_call(slot, 1)); // ReadContent -> content_reads + try std.testing.expectEqual(@as(c_int, 0), google_drive_mcp_record_call(slot, 13)); // Trash -> mutations + try std.testing.expectEqual(@as(c_int, 0), google_drive_mcp_record_call(slot, 0)); // Search -> search_queries + try std.testing.expectEqual(@as(c_int, 0), google_drive_mcp_record_call(slot, 16)); // StorageQuota -> admin_queries + + try std.testing.expectEqual(@as(c_int, 4), google_drive_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_drive_mcp_content_read_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_drive_mcp_mutation_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_drive_mcp_search_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_drive_mcp_admin_query_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), google_drive_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), google_drive_mcp_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), google_drive_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), google_drive_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 0), google_drive_mcp_can_transition(2, 3)); + try std.testing.expectEqual(@as(c_int, 0), google_drive_mcp_can_transition(0, 2)); +} + +test "slot exhaustion" { + google_drive_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = google_drive_mcp_connect(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), google_drive_mcp_connect(0)); + + try std.testing.expectEqual(@as(c_int, 0), google_drive_mcp_disconnect(slots[0])); + const new_slot = google_drive_mcp_connect(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns google-drive-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("google-drive-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "gdrive_search", + "gdrive_read_content", + "gdrive_export", + "gdrive_get_metadata", + "gdrive_list_recent", + "gdrive_list_folder", + "gdrive_get_permissions", + "gdrive_list_revisions", + "gdrive_storage_quota", + "gdrive_list_shared_drives", + "gdrive_share", + "gdrive_copy", + "gdrive_create_file", + "gdrive_update_content", + "gdrive_move", + "gdrive_rename", + "gdrive_trash", + "gdrive_restore", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("gdrive_search", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/google-drive-mcp/minter.toml b/cartridges/google-drive-mcp/minter.toml new file mode 100644 index 00000000..bb545e7d --- /dev/null +++ b/cartridges/google-drive-mcp/minter.toml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +name = "google-drive-mcp" +description = "Google Drive cartridge — full-query search, export-aware reading, folder listing, metadata, permissions + sharing, copy, create, update, move, rename, reversible trash/restore, revisions, format export, quota, shared drives" +version = "0.1.0" +domain = "Productivity" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "oauth2" +credential_source = "vault-mcp" +fields = ["google_drive_access_token"] + +[api] +base_url = "https://www.googleapis.com/drive/v3" +local_only = false diff --git a/cartridges/google-drive-mcp/mod.js b/cartridges/google-drive-mcp/mod.js new file mode 100644 index 00000000..b1e746e3 --- /dev/null +++ b/cartridges/google-drive-mcp/mod.js @@ -0,0 +1,464 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// google-drive-mcp/mod.js -- Google Drive cartridge implementation. +// +// Provides MCP tool handlers for the Google Drive API v3: +// - Full-query search (files.list q=) incl. shared drives + pagination +// - Export-aware content reading (Docs->markdown/text, Sheets->CSV) +// - Explicit format export (pdf/docx/md/csv/...) +// - Metadata, folder listing, recents, revisions, storage quota +// - Permissions listing and granting (share) +// - Copy, create (with inline content), update content, move, rename +// - REVERSIBLE trash/restore -- permanent files.delete is deliberately +// NOT implemented (fail-safe: recovery is always possible) +// - Shared-drive enumeration +// +// Auth: OAuth2 Bearer token via GOOGLE_DRIVE_ACCESS_TOKEN (required). +// API docs: https://developers.google.com/drive/api/reference/rest/v3 +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const DRIVE_API_BASE = "https://www.googleapis.com/drive/v3"; +const UPLOAD_API_BASE = "https://www.googleapis.com/upload/drive/v3"; + +// Fields we ask for on file resources -- one place to keep them consistent. +const FILE_FIELDS = + "id,name,mimeType,size,createdTime,modifiedTime,owners(displayName,emailAddress)," + + "parents,shared,webViewLink,iconLink,trashed,capabilities(canEdit,canShare,canTrash)"; + +// Google-native types and their default export mappings. +const EXPORT_DEFAULTS = { + "application/vnd.google-apps.document": { md: "text/markdown", plain: "text/plain" }, + "application/vnd.google-apps.spreadsheet": { md: "text/csv", plain: "text/csv" }, + "application/vnd.google-apps.presentation": { md: "text/plain", plain: "text/plain" }, +}; + +// --------------------------------------------------------------------------- +// Auth helper -- retrieves the Google OAuth2 access token from environment. +// In production, vault-mcp provides zero-knowledge credential proxying. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("GOOGLE_DRIVE_ACCESS_TOKEN") + : process.env.GOOGLE_DRIVE_ACCESS_TOKEN; + return token || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helpers -- wrap fetch with Google API headers, OAuth2 bearer +// auth, and error normalisation. driveFetch returns JSON; driveFetchRaw +// returns the raw body (for content download/export). +// --------------------------------------------------------------------------- + +async function driveRequest(base, path, queryParams, method, body, bodyContentType) { + const url = new URL(`${base}${path}`); + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const token = getToken(); + if (!token) { + return { + error: "GOOGLE_DRIVE_ACCESS_TOKEN is not set. Provide an OAuth2 access token " + + "with drive scope (via vault-mcp in production).", + }; + } + + const headers = { + "Accept": "*/*", + "Authorization": `Bearer ${token}`, + "User-Agent": "boj-server/google-drive-mcp/0.1.0", + }; + if (body !== undefined && bodyContentType) headers["Content-Type"] = bodyContentType; + + let response; + try { + response = await fetch(url.toString(), { + method: method || "GET", + headers, + body: body === undefined ? undefined + : (typeof body === "string" ? body : JSON.stringify(body)), + }); + } catch (err) { + return { error: `Network error calling Google Drive API: ${err.message}` }; + } + return { response }; +} + +async function driveFetch(path, queryParams, method, jsonBody) { + const r = await driveRequest( + DRIVE_API_BASE, path, queryParams, method, + jsonBody === undefined ? undefined : jsonBody, "application/json", + ); + if (r.error) return r; + const { response } = r; + let data = null; + const text = await response.text(); + if (text) { + try { data = JSON.parse(text); } catch { data = { raw: text }; } + } + if (!response.ok) { + const message = data && data.error && data.error.message ? data.error.message : text; + return { error: `Google Drive API ${response.status}: ${message}`, status: response.status }; + } + return { status: response.status, data }; +} + +async function driveFetchRaw(path, queryParams) { + const r = await driveRequest(DRIVE_API_BASE, path, queryParams, "GET"); + if (r.error) return r; + const { response } = r; + if (!response.ok) { + const text = await response.text(); + return { error: `Google Drive API ${response.status}: ${text}`, status: response.status }; + } + const contentType = response.headers.get("content-type") || "application/octet-stream"; + const bytes = new Uint8Array(await response.arrayBuffer()); + return { status: response.status, contentType, bytes }; +} + +function bytesToBase64(bytes) { + let bin = ""; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(bin); +} + +function isTextual(contentType) { + return /^text\/|json|xml|csv|markdown|javascript/.test(contentType); +} + +// Common list params for shared-drive-aware queries. +function sharedDriveParams(include) { + return include === false + ? {} + : { supportsAllDrives: true, includeItemsFromAllDrives: true }; +} + +// --------------------------------------------------------------------------- +// Tool handlers +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + args = args || {}; + switch (toolName) { + case "gdrive_search": { + if (args.query === undefined) return { error: "Missing required field: query" }; + const params = { + q: args.query, + pageSize: Math.min(Math.max(args.page_size || 25, 1), 1000), + pageToken: args.page_token, + orderBy: args.order_by, + fields: `nextPageToken,files(${FILE_FIELDS})`, + ...sharedDriveParams(args.include_shared_drives), + }; + if (args.drive_id) { + params.driveId = args.drive_id; + params.corpora = "drive"; + } + return await driveFetch("/files", params); + } + + case "gdrive_read_content": { + if (!args.file_id) return { error: "Missing required field: file_id" }; + const maxBytes = args.max_bytes || 1048576; + const meta = await driveFetch(`/files/${encodeURIComponent(args.file_id)}`, { + fields: "id,name,mimeType,size", supportsAllDrives: true, + }); + if (meta.error) return meta; + const mime = meta.data.mimeType || ""; + const exportMap = EXPORT_DEFAULTS[mime]; + let raw; + if (exportMap) { + const target = args.prefer_markdown === false ? exportMap.plain : exportMap.md; + raw = await driveFetchRaw( + `/files/${encodeURIComponent(args.file_id)}/export`, { mimeType: target }, + ); + } else { + if (meta.data.size && Number(meta.data.size) > maxBytes) { + return { + error: `File is ${meta.data.size} bytes, over the ${maxBytes}-byte cap. ` + + "Raise max_bytes or use gdrive_export.", + }; + } + raw = await driveFetchRaw( + `/files/${encodeURIComponent(args.file_id)}`, { alt: "media", supportsAllDrives: true }, + ); + } + if (raw.error) return raw; + if (raw.bytes.length > maxBytes) { + return { error: `Content is ${raw.bytes.length} bytes, over the ${maxBytes}-byte cap.` }; + } + if (isTextual(raw.contentType)) { + return { + status: raw.status, + data: { + name: meta.data.name, mimeType: mime, contentType: raw.contentType, + content: new TextDecoder().decode(raw.bytes), + }, + }; + } + return { + status: raw.status, + data: { + name: meta.data.name, mimeType: mime, contentType: raw.contentType, + encoding: "base64", content: bytesToBase64(raw.bytes), + }, + }; + } + + case "gdrive_export": { + if (!args.file_id) return { error: "Missing required field: file_id" }; + if (!args.mime_type) return { error: "Missing required field: mime_type" }; + const raw = await driveFetchRaw( + `/files/${encodeURIComponent(args.file_id)}/export`, { mimeType: args.mime_type }, + ); + if (raw.error) return raw; + if (isTextual(raw.contentType)) { + return { + status: raw.status, + data: { contentType: raw.contentType, content: new TextDecoder().decode(raw.bytes) }, + }; + } + return { + status: raw.status, + data: { contentType: raw.contentType, encoding: "base64", content: bytesToBase64(raw.bytes) }, + }; + } + + case "gdrive_get_metadata": { + if (!args.file_id) return { error: "Missing required field: file_id" }; + return await driveFetch(`/files/${encodeURIComponent(args.file_id)}`, { + fields: FILE_FIELDS + ",description,starred,version,md5Checksum,quotaBytesUsed", + supportsAllDrives: true, + }); + } + + case "gdrive_list_recent": { + return await driveFetch("/files", { + q: "trashed = false", + orderBy: "modifiedTime desc", + pageSize: Math.min(args.page_size || 20, 100), + fields: `files(${FILE_FIELDS})`, + ...sharedDriveParams(args.include_shared_drives), + }); + } + + case "gdrive_list_folder": { + if (!args.folder_id) return { error: "Missing required field: folder_id" }; + return await driveFetch("/files", { + q: `'${args.folder_id.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}' in parents and trashed = false`, + orderBy: args.order_by || "folder,name", + pageSize: Math.min(args.page_size || 50, 1000), + pageToken: args.page_token, + fields: `nextPageToken,files(${FILE_FIELDS})`, + ...sharedDriveParams(true), + }); + } + + case "gdrive_get_permissions": { + if (!args.file_id) return { error: "Missing required field: file_id" }; + return await driveFetch(`/files/${encodeURIComponent(args.file_id)}/permissions`, { + fields: "permissions(id,type,role,emailAddress,domain,displayName,expirationTime,deleted)", + supportsAllDrives: true, + }); + } + + case "gdrive_share": { + if (!args.file_id) return { error: "Missing required field: file_id" }; + if (!args.role) return { error: "Missing required field: role" }; + if (!args.grantee_type) return { error: "Missing required field: grantee_type" }; + if (!["reader", "commenter", "writer"].includes(args.role)) { + return { error: `Unsupported role '${args.role}' (owner transfer is deliberately not offered)` }; + } + const permission = { type: args.grantee_type, role: args.role }; + if (args.grantee_type === "user" || args.grantee_type === "group") { + if (!args.email) return { error: "email is required for user/group grants" }; + permission.emailAddress = args.email; + } else if (args.grantee_type === "domain") { + if (!args.domain) return { error: "domain is required for domain grants" }; + permission.domain = args.domain; + } + return await driveFetch( + `/files/${encodeURIComponent(args.file_id)}/permissions`, + { + sendNotificationEmail: args.send_notification === true, + supportsAllDrives: true, + }, + "POST", permission, + ); + } + + case "gdrive_copy": { + if (!args.file_id) return { error: "Missing required field: file_id" }; + const body = {}; + if (args.name) body.name = args.name; + if (args.parent_folder_id) body.parents = [args.parent_folder_id]; + return await driveFetch( + `/files/${encodeURIComponent(args.file_id)}/copy`, + { fields: FILE_FIELDS, supportsAllDrives: true }, "POST", body, + ); + } + + case "gdrive_create_file": { + if (!args.name) return { error: "Missing required field: name" }; + const mime = args.mime_type || (args.content !== undefined ? "text/plain" : undefined); + const meta = { name: args.name }; + if (mime) meta.mimeType = mime; + meta.parents = [args.parent_folder_id || "root"]; + + if (args.content === undefined || mime === "application/vnd.google-apps.folder") { + // Metadata-only create (folders, empty files, empty Google Docs). + return await driveFetch("/files", { fields: FILE_FIELDS, supportsAllDrives: true }, "POST", meta); + } + + // Multipart upload: metadata + inline text content in one call. + const boundary = "boj-gdrive-" + Math.random().toString(36).slice(2); + const contentMime = mime && !mime.startsWith("application/vnd.google-apps") + ? mime : "text/plain"; + const multipart = + `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n` + + JSON.stringify(meta) + + `\r\n--${boundary}\r\nContent-Type: ${contentMime}\r\n\r\n` + + args.content + + `\r\n--${boundary}--`; + const r = await driveRequest( + UPLOAD_API_BASE, "/files", + { uploadType: "multipart", fields: FILE_FIELDS, supportsAllDrives: true }, + "POST", multipart, `multipart/related; boundary=${boundary}`, + ); + if (r.error) return r; + const text = await r.response.text(); + let data; + try { data = JSON.parse(text); } catch { data = { raw: text }; } + if (!r.response.ok) { + return { error: `Google Drive API ${r.response.status}: ${text}`, status: r.response.status }; + } + return { status: r.response.status, data }; + } + + case "gdrive_update_content": { + if (!args.file_id) return { error: "Missing required field: file_id" }; + if (args.content === undefined) return { error: "Missing required field: content" }; + const r = await driveRequest( + UPLOAD_API_BASE, `/files/${encodeURIComponent(args.file_id)}`, + { uploadType: "media", supportsAllDrives: true }, + "PATCH", args.content, args.mime_type || "text/plain", + ); + if (r.error) return r; + const text = await r.response.text(); + let data; + try { data = JSON.parse(text); } catch { data = { raw: text }; } + if (!r.response.ok) { + return { error: `Google Drive API ${r.response.status}: ${text}`, status: r.response.status }; + } + return { status: r.response.status, data }; + } + + case "gdrive_move": { + if (!args.file_id) return { error: "Missing required field: file_id" }; + if (!args.destination_folder_id) return { error: "Missing required field: destination_folder_id" }; + const meta = await driveFetch(`/files/${encodeURIComponent(args.file_id)}`, { + fields: "parents", supportsAllDrives: true, + }); + if (meta.error) return meta; + const oldParents = (meta.data.parents || []).join(","); + return await driveFetch( + `/files/${encodeURIComponent(args.file_id)}`, + { + addParents: args.destination_folder_id, + removeParents: oldParents, + fields: FILE_FIELDS, + supportsAllDrives: true, + }, + "PATCH", {}, + ); + } + + case "gdrive_rename": { + if (!args.file_id) return { error: "Missing required field: file_id" }; + if (!args.new_name) return { error: "Missing required field: new_name" }; + return await driveFetch( + `/files/${encodeURIComponent(args.file_id)}`, + { fields: FILE_FIELDS, supportsAllDrives: true }, + "PATCH", { name: args.new_name }, + ); + } + + case "gdrive_trash": { + if (!args.file_id) return { error: "Missing required field: file_id" }; + // Reversible by design; files.delete (permanent) is deliberately absent. + return await driveFetch( + `/files/${encodeURIComponent(args.file_id)}`, + { fields: "id,name,trashed", supportsAllDrives: true }, + "PATCH", { trashed: true }, + ); + } + + case "gdrive_restore": { + if (!args.file_id) return { error: "Missing required field: file_id" }; + return await driveFetch( + `/files/${encodeURIComponent(args.file_id)}`, + { fields: "id,name,trashed", supportsAllDrives: true }, + "PATCH", { trashed: false }, + ); + } + + case "gdrive_list_revisions": { + if (!args.file_id) return { error: "Missing required field: file_id" }; + return await driveFetch(`/files/${encodeURIComponent(args.file_id)}/revisions`, { + pageSize: Math.min(args.page_size || 20, 200), + fields: "revisions(id,modifiedTime,lastModifyingUser(displayName,emailAddress)," + + "size,keepForever,originalFilename,exportLinks)", + }); + } + + case "gdrive_storage_quota": { + const result = await driveFetch("/about", { fields: "storageQuota,user(displayName,emailAddress)" }); + if (result.error) return result; + const q = result.data.storageQuota || {}; + return { + status: result.status, + data: { + user: result.data.user, + limit: q.limit, usage: q.usage, + usageInDrive: q.usageInDrive, usageInDriveTrash: q.usageInDriveTrash, + }, + }; + } + + case "gdrive_list_shared_drives": { + return await driveFetch("/drives", { + pageSize: Math.min(args.page_size || 20, 100), + pageToken: args.page_token, + fields: "nextPageToken,drives(id,name,createdTime,capabilities(canManageMembers))", + }); + } + + default: + return { error: `Unknown google-drive-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export -- used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "google-drive-mcp", + version: "0.1.0", + domain: "Productivity", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 18, +}; diff --git a/cartridges/google-drive-mcp/panels/manifest.json b/cartridges/google-drive-mcp/panels/manifest.json new file mode 100644 index 00000000..83a93137 --- /dev/null +++ b/cartridges/google-drive-mcp/panels/manifest.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "google-drive-mcp", + "domain": "Productivity", + "version": "0.1.0", + "panels": [ + { + "id": "gdrive-connection-status", + "title": "Connection Status", + "description": "Google Drive session state (Disconnected / Connected / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/google-drive/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "wifi-off" }, + "connected": { "color": "#2ecc71", "icon": "wifi" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "gdrive-storage-quota", + "title": "Storage Quota", + "description": "Account storage usage vs limit (Drive + Trash split)", + "type": "gauge", + "data_source": { + "endpoint": "/google-drive/quota", + "method": "GET", + "refresh_interval_ms": 60000 + }, + "widgets": [ + { "type": "progress-gauge", "field": "usage", "max_field": "limit" }, + { "type": "stat", "field": "usageInDriveTrash", "label": "In bin (recoverable)" } + ] + }, + { + "id": "gdrive-recent-activity", + "title": "Recent Files", + "description": "Most recently modified files", + "type": "list", + "data_source": { + "endpoint": "/google-drive/recent", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "item-list", "fields": ["name", "modifiedTime", "mimeType"], "limit": 10 } + ] + } + ] +} diff --git a/cartridges/google-drive-mcp/tests/integration_test.sh b/cartridges/google-drive-mcp/tests/integration_test.sh new file mode 100755 index 00000000..d4c3fe98 --- /dev/null +++ b/cartridges/google-drive-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for google-drive-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== google-drive-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check GoogleDriveMcp/SafeRegistry.idr 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for google-drive-mcp!"