Google Workspace
Build an MCP server with Google Calendar and Drive tools using Keycard delegated access
Build an MCP server with Calendar and Drive tools that let AI agents manage a user’s Google Workspace on their behalf.
Google Setup
Section titled “Google Setup”-
Copy your Keycard redirect URL
In Keycard Console, open Settings → Connection and copy the Redirect URL. You will need this when creating the Google OAuth App.
-
Create a Google OAuth App
In Google Cloud Console → APIs & Services → Credentials, create an OAuth 2.0 Client ID (application type: Web application). Add the redirect URL you copied from Keycard to Authorized redirect URIs. Note the Client ID and Client Secret.
Also enable the Calendar API and Drive API under APIs & Services → Enabled APIs.
-
Add Google from the catalog
In Keycard Console, open Resources → Add Resource → Explore Resources and add Google Calendar API and Google Drive API. Enter the Client ID and Client Secret from step 2. This creates the Google OAuth provider and API resources.
Then open each of these Resources and, in its Scopes section, click Add scope for its Google scope, then Save Changes:
https://www.googleapis.com/auth/calendar.readonlyfor Google Calendar API,https://www.googleapis.com/auth/drive.readonlyfor Google Drive API. The catalog creates both with no scopes, and Google rejects an authorization request without one. -
Register your MCP server Resource
Navigate to Resources → Add Resource → Add Manually:
Field Value Name Google Workspace MCP Server Identifier http://localhost:8000/mcpCredential Provider Zone Provider -
Create an Application
Navigate to Applications → Add Application, set Name and Identifier (for example
google-workspace-mcp), and click Create application.Then, on the application details page:
- On the Provides tab, click Add provided resource and select your Google Workspace MCP Server.
- On the Dependencies tab, click Add dependency and select Google Calendar API and Google Drive API.
After creating the Application, generate client credentials and save them.
Implementation
Section titled “Implementation”The implementation follows the same pattern as the GitHub server. The only differences are the API calls you make with the token and the URLs you pass to grant(), which are the Google Resources’ identifiers.
pip install keycardai-fastmcp fastmcp httpxSet the environment variables:
export KEYCARD_ISSUER=<keycard-issuer-url>export MCP_SERVER_URL=http://localhost:8000export KEYCARD_CLIENT_ID=<your-client-id>export KEYCARD_CLIENT_SECRET=<your-client-secret>The server setup is identical to GitHub: just change the server name. The tools file uses the same grant pattern:
"""Google Workspace tools: Calendar and Drive."""
import httpxfrom fastmcp import Context, FastMCP
from keycardai.fastmcp import AccessContext, AuthProvider
CALENDAR_API = "https://www.googleapis.com/calendar/v3"DRIVE_API = "https://www.googleapis.com/drive/v3"
def register_tools(mcp: FastMCP, auth_provider: AuthProvider): @mcp.tool() @auth_provider.grant(CALENDAR_API) async def list_calendar_events(ctx: Context, calendar_id: str = "primary") -> dict: """List events from a Google Calendar.""" access_context: AccessContext = await ctx.get_state("keycardai") if access_context.has_errors(): return {"error": "Token exchange failed", "details": access_context.get_errors()} token = access_context.access(CALENDAR_API).access_token
# Use token to call Google Calendar API async with httpx.AsyncClient() as client: response = await client.get( f"{CALENDAR_API}/calendars/{calendar_id}/events", headers={"Authorization": f"Bearer {token}"}, params={"singleEvents": "true", "orderBy": "startTime"}, ) response.raise_for_status() return { "events": [ {"summary": e.get("summary"), "start": e.get("start")} for e in response.json().get("items", []) ] }
@mcp.tool() @auth_provider.grant(DRIVE_API) async def list_drive_files(ctx: Context, q: str | None = None) -> dict: """List or search files in Google Drive.""" access_context: AccessContext = await ctx.get_state("keycardai") if access_context.has_errors(): return {"error": "Token exchange failed", "details": access_context.get_errors()} token = access_context.access(DRIVE_API).access_token
# Use token to call Google Drive API async with httpx.AsyncClient() as client: response = await client.get( f"{DRIVE_API}/files", headers={"Authorization": f"Bearer {token}"}, params={"q": q} if q else None, ) response.raise_for_status() return {"files": response.json().get("files", [])}For more error handling patterns, see the SDK’s delegated access example (GitHub): Python SDK examples
npm install @keycardai/mcp @modelcontextprotocol/sdk expressnpm install -D typescript @types/express tsxSet the environment variables:
export KEYCARD_ISSUER=<keycard-issuer-url>export KEYCARD_CLIENT_ID=<your-client-id>export KEYCARD_CLIENT_SECRET=<your-client-secret>The server setup is identical to GitHub: change the server name, import CALENDAR_API, DRIVE_API and registerGoogleTools in place of the GitHub ones, call registerGoogleTools, and pass [CALENDAR_API, DRIVE_API] to grant(). The tools file uses the same grant pattern:
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import type { AccessContext } from "@keycardai/mcp/server/auth/provider";
export const CALENDAR_API = "https://www.googleapis.com/calendar/v3";export const DRIVE_API = "https://www.googleapis.com/drive/v3";
export function registerGoogleTools(server: McpServer, accessContext: AccessContext) { server.tool( "list_calendar_events", "List events from your primary Google Calendar.", async () => { if (accessContext.hasErrors()) { return { content: [{ type: "text", text: "Token exchange failed" }], isError: true }; } const token = accessContext.access(CALENDAR_API).accessToken;
// Use token to call Google Calendar API const response = await fetch( `${CALENDAR_API}/calendars/primary/events?singleEvents=true&orderBy=startTime`, { headers: { Authorization: `Bearer ${token}` } }, ); return { content: [{ type: "text", text: await response.text() }], isError: !response.ok }; }, );
server.tool( "list_drive_files", "List files in Google Drive.", async () => { if (accessContext.hasErrors()) { return { content: [{ type: "text", text: "Token exchange failed" }], isError: true }; } const token = accessContext.access(DRIVE_API).accessToken;
// Use token to call Google Drive API const response = await fetch(`${DRIVE_API}/files`, { headers: { Authorization: `Bearer ${token}` }, }); return { content: [{ type: "text", text: await response.text() }], isError: !response.ok }; }, );}See the full example with all tools, error handling, and Google Workspace file export logic: TypeScript SDK examples
go mod init google-mcp-servergo get github.com/keycardai/go-sdk/mcpgo get github.com/mark3labs/mcp-goSet the environment variables:
export KEYCARD_ISSUER=<keycard-issuer-url>export KEYCARD_CLIENT_ID=<your-client-id>export KEYCARD_CLIENT_SECRET=<your-client-secret>The server setup is identical to GitHub: change the server name and pass []string{calendarAPI, driveAPI} to Grant. The tools file uses the same pattern:
package main
import ( "context" "io" "net/http"
"github.com/keycardai/go-sdk/mcp" mcpgo "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server")
const ( calendarAPI = "https://www.googleapis.com/calendar/v3" driveAPI = "https://www.googleapis.com/drive/v3")
func registerTools(s *server.MCPServer) { s.AddTool( mcpgo.NewTool("list_calendar_events", mcpgo.WithDescription("List events from a Google Calendar."), ), func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { ac := mcp.AccessContextFromContext(ctx) token, err := ac.Access(calendarAPI) if err != nil { return mcpgo.NewToolResultError("Token exchange failed"), nil }
// Use token to call Google Calendar API req, _ := http.NewRequestWithContext(ctx, "GET", calendarAPI+"/calendars/primary/events?singleEvents=true&orderBy=startTime", nil) req.Header.Set("Authorization", "Bearer "+token.AccessToken)
resp, err := http.DefaultClient.Do(req) if err != nil { return mcpgo.NewToolResultError(err.Error()), nil } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return mcpgo.NewToolResultText(string(body)), nil }, )
s.AddTool( mcpgo.NewTool("list_drive_files", mcpgo.WithDescription("List or search files in Google Drive."), ), func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { ac := mcp.AccessContextFromContext(ctx) token, err := ac.Access(driveAPI) if err != nil { return mcpgo.NewToolResultError("Token exchange failed"), nil }
// Use token to call Google Drive API req, _ := http.NewRequestWithContext(ctx, "GET", driveAPI+"/files", nil) req.Header.Set("Authorization", "Bearer "+token.AccessToken)
resp, err := http.DefaultClient.Do(req) if err != nil { return mcpgo.NewToolResultError(err.Error()), nil } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return mcpgo.NewToolResultText(string(body)), nil }, )}See the full delegated access example: Go SDK examples
bundle initbundle add keycardai-mcp mcp rackup webrickSet the environment variables:
export KEYCARD_ISSUER=<keycard-issuer-url>export KEYCARD_CLIENT_ID=<your-client-id>export KEYCARD_CLIENT_SECRET=<your-client-secret>The server setup is identical to GitHub: change the server name and pass [CALENDAR_API, DRIVE_API] to auth_provider.grant. The tools file uses the same pattern:
require "net/http"
CALENDAR_API = "https://www.googleapis.com/calendar/v3"DRIVE_API = "https://www.googleapis.com/drive/v3"
def build_mcp_server(access_context) mcp_server = MCP::Server.new( name: "Google Workspace MCP Server", version: "1.0.0", server_context: { access_context: access_context }, )
mcp_server.define_tool( name: "list_calendar_events", description: "List events from a Google Calendar", input_schema: { properties: { calendar_id: { type: "string" } }, required: [] }, ) do |calendar_id: "primary", server_context:| token = server_context[:access_context].access(CALENDAR_API).access_token
# Use token to call Google Calendar API uri = URI("#{CALENDAR_API}/calendars/#{calendar_id}/events?singleEvents=true&orderBy=startTime") request = Net::HTTP::Get.new(uri) request["Authorization"] = "Bearer #{token}" response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) } MCP::Tool::Response.new([{ type: "text", text: response.body }]) end
mcp_server.define_tool( name: "list_drive_files", description: "List or search files in Google Drive", input_schema: { properties: { q: { type: "string" } }, required: [] }, ) do |q: nil, server_context:| token = server_context[:access_context].access(DRIVE_API).access_token
# Use token to call Google Drive API uri = URI("#{DRIVE_API}/files") uri.query = URI.encode_www_form(q: q) if q request = Net::HTTP::Get.new(uri) request["Authorization"] = "Bearer #{token}" response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) } MCP::Tool::Response.new([{ type: "text", text: response.body }]) end
mcp_serverendSee the full example MCP server: Ruby SDK examples
Test It
Section titled “Test It”-
Start your server
Terminal window python server.pyTerminal window npx tsx server.tsTerminal window go mod tidygo run .Terminal window bundle exec rackup -p 8000 -
Configure your agent
Add the MCP server to your agent configuration:
Cursor: add to
.cursor/mcp.json:{"mcpServers": {"google-mcp": {"url": "http://localhost:8000/mcp"}}}Claude Code: run in your terminal:
Terminal window claude mcp add --transport http google-mcp http://localhost:8000/mcp -
Authenticate
Restart your coding agent to detect the server, then connect to it. In Claude Code, run
/mcp, select the server and choose Authenticate (or runclaude mcp login google-mcp). A browser window opens on Keycard; if it asks you to sign in, use the account you use for Keycard Console. If you set this up in a custom Zone instead, you sign in through the identity provider chosen for that Zone. You will then be prompted to authorize Google Calendar and Drive access. -
Try these prompts:
- “Show my calendar events for this week”
- “List my recent Drive files”
- “Find the Drive file named ‘Meeting Notes’”
-
Verify in Audit Logs
Check Keycard Console Audit Log. Each hop has a
credentials:issueevent whose identity chain shows your agent or Application, then you.users:authenticateappears only if you signed in during this flow, andusers:authorizeonly when Google asked you to authorize.
What’s next
Section titled “What’s next”- Run on Render without secrets: run this server in production with Render’s workload identity in place of
KEYCARD_CLIENT_SECRET.