Third-Party Tools & APIs
Build MCP servers that access GitHub, Google Workspace, and other APIs on behalf of users
Your AI agents need to do more than just answer questions — they need to take action. Read a user’s calendar, list their repos, create issues, search their Drive. But accessing APIs on behalf of a user means managing OAuth flows, token exchange, credential storage, and refresh logic across every provider.
Keycard handles all of that. You write MCP tools that call APIs; Keycard manages the authentication plumbing, exchanges tokens per-user, and gives you an audit trail of every action taken.
This guide walks through building GitHub and Google Workspace MCP servers with delegated access. Full working examples are available in the SDK repos.
How Token Exchange Works
Section titled “How Token Exchange Works”Token exchange lets your MCP server act as both a protected resource (receiving authenticated requests from AI agents) and an API client (calling upstream APIs on behalf of the authenticated user).
flowchart LR
A[User] -->|Token A| B[AI Agent]
B -->|Token A| C[Your MCP Server]
C -->|Exchange A → B| D[Keycard]
D -->|Token B| C
C -->|Token B| E[External API]- User authenticates to your MCP server with a Keycard token (Token A)
- Your MCP server exchanges Token A for an external API token (Token B)
- Your MCP server calls the external API with the exchanged token
- The user’s data is accessed with their own permissions — not a shared service account
Prerequisites
Section titled “Prerequisites”- Completed Build an MCP Server guide
- Python 3.10+, Node.js 18+, or Go 1.21+
- GitHub account (for GitHub section)
- Google Cloud account (for Google section)
GitHub MCP Server
Section titled “GitHub MCP Server”Build an MCP server with repository-focused tools that let AI agents interact with GitHub on behalf of users.
Keycard Setup
Section titled “Keycard Setup”-
Add GitHub from Resource Catalog
In Keycard Console, navigate to Resource Catalog and add GitHub. This automatically creates the GitHub OAuth provider and GitHub API resource.
-
Set up the GitHub OAuth App callback
- Go to Zone Settings in Keycard Console and copy the OAuth2 Redirect URL
- In GitHub Developer Settings, create or edit your OAuth App
- Set the Authorization callback URL to the Keycard redirect URL
-
Register your MCP server resource
Navigate to Resources → Create Resource:
Field Value Resource Name GitHub MCP Server Resource Identifier http://localhost:8000/mcpCredential Provider Zone Provider -
Create an Application
Navigate to Applications → Create Application:
Field Value Provided Resource GitHub MCP Server (your MCP server) Dependency GitHub API After creating the application, generate client credentials (Client ID + Client Secret) and save them.
Implementation
Section titled “Implementation”The server is split into two files: setup and tools. The setup file configures Keycard authentication; the tools file contains your API wrappers.
Install dependencies:
pip install keycardai-mcp-fastmcp fastmcp httpxCreate a .env file:
KEYCARD_ZONE_ID=your-zone-idMCP_SERVER_URL=http://localhost:8000KEYCARD_CLIENT_ID=your-client-idKEYCARD_CLIENT_SECRET=your-client-secretserver.py — Server setup and entry point:
"""GitHub MCP Server with Keycard Delegated Access."""
import os
from fastmcp import FastMCP
from keycardai.mcp.integrations.fastmcp import AuthProvider, ClientSecret
auth_provider = AuthProvider( zone_id=os.getenv("KEYCARD_ZONE_ID"), mcp_server_name="GitHub MCP Server", mcp_base_url=os.getenv("MCP_SERVER_URL", "http://localhost:8000"), application_credential=ClientSecret(( os.getenv("KEYCARD_CLIENT_ID"), os.getenv("KEYCARD_CLIENT_SECRET"), )),)
auth = auth_provider.get_remote_auth_provider()mcp = FastMCP("GitHub MCP Server", auth=auth)
from tools import register_tools # noqa: E402
register_tools(mcp, auth_provider)
def main(): mcp.run(transport="streamable-http")
if __name__ == "__main__": main()tools.py — Each tool uses the grant decorator and extracts the exchanged token:
"""GitHub tools — thin wrappers around the GitHub REST API."""
import httpxfrom fastmcp import Context, FastMCP
from keycardai.mcp.integrations.fastmcp import AccessContext, AuthProvider
GITHUB_API = "https://api.github.com"
async def github_request(access_context: AccessContext, method: str, path: str, **kwargs) -> dict: """Make an authenticated request to the GitHub API.""" token = access_context.access(GITHUB_API).access_token async with httpx.AsyncClient() as client: response = await client.request( method, f"{GITHUB_API}{path}", headers={ "Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json", }, **kwargs, ) response.raise_for_status() return response.json()
def register_tools(mcp: FastMCP, auth_provider: AuthProvider): @mcp.tool() @auth_provider.grant(GITHUB_API) async def list_repos(ctx: Context, per_page: int = 30, sort: str = "updated") -> dict: """List the authenticated user's repositories.""" access_context: AccessContext = ctx.get_state("keycardai") if access_context.has_errors(): return {"error": "Token exchange failed", "details": access_context.get_errors()}
repos = await github_request( access_context, "GET", "/user/repos", params={"per_page": per_page, "sort": sort} ) return { "count": len(repos), "repositories": [ {"name": r["name"], "full_name": r["full_name"], "html_url": r["html_url"]} for r in repos ], }
# Additional tools follow the same pattern: grant → get_state → check errors → call API # See full examples: https://github.com/keycardai/python-sdk/tree/main/packages/mcp-fastmcp/examplesInstall dependencies:
npm install @keycardai/mcp @modelcontextprotocol/sdk expressnpm install -D typescript @types/expressCreate a .env file:
KEYCARD_ZONE_URL=https://your-zone.keycard.cloudKEYCARD_CLIENT_ID=your-client-idKEYCARD_CLIENT_SECRET=your-client-secretPORT=8080server.ts — Server setup and entry point:
import express from "express";import { mcpAuthMetadataRouter } from "@keycardai/mcp/server/auth/router";import { AuthProvider } from "@keycardai/mcp/server/auth/provider";import { ClientSecret } from "@keycardai/mcp/server/auth/credentials";import { registerGitHubRoutes } from "./tools";
const ZONE_URL = process.env.KEYCARD_ZONE_URL ?? "https://your-zone.keycard.cloud";const CLIENT_ID = process.env.KEYCARD_CLIENT_ID ?? "your-client-id";const CLIENT_SECRET = process.env.KEYCARD_CLIENT_SECRET ?? "your-client-secret";const PORT = Number(process.env.PORT ?? 8080);
const authProvider = new AuthProvider({ zoneUrl: ZONE_URL, applicationCredential: new ClientSecret(CLIENT_ID, CLIENT_SECRET),});
const app = express();app.use(express.json());
app.use( mcpAuthMetadataRouter({ oauthMetadata: { issuer: ZONE_URL }, resourceName: "GitHub MCP Server", }),);
registerGitHubRoutes(app, authProvider);
app.listen(PORT, () => { console.log(`GitHub MCP Server running on http://localhost:${PORT}`);});tools.ts — Each route uses the grant middleware and extracts the exchanged token:
import type { Express } from "express";import type { AuthProvider, DelegatedRequest } from "@keycardai/mcp/server/auth/provider";
const GITHUB_API = "https://api.github.com";
async function githubRequest(token: string, path: string, options?: RequestInit) { const response = await fetch(`${GITHUB_API}${path}`, { ...options, headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", ...options?.headers, }, }); if (!response.ok) throw new Error(`GitHub API error: ${response.status}`); return response.json();}
export function registerGitHubRoutes(app: Express, authProvider: AuthProvider) { app.get("/api/repos", authProvider.grant(GITHUB_API), async (req, res) => { const { accessContext } = req as DelegatedRequest; if (accessContext.hasErrors()) { res.status(502).json({ error: "Token exchange failed", details: accessContext.getErrors() }); return; } const token = accessContext.access(GITHUB_API).accessToken; const repos = await githubRequest(token, "/user/repos?per_page=30&sort=updated"); res.json({ count: repos.length, repositories: repos.map((r: any) => ({ name: r.name, full_name: r.full_name, html_url: r.html_url, })), }); });
// Additional routes follow the same pattern: grant → accessContext → check errors → call API // See full examples: https://github.com/keycardai/typescript-sdk/tree/main/examples}Install dependencies:
go mod init github-mcp-servergo get github.com/keycardai/credentials-go/mcpCreate a .env file:
KEYCARD_ZONE_URL=https://your-zone.keycard.cloudKEYCARD_CLIENT_ID=your-client-idKEYCARD_CLIENT_SECRET=your-client-secretPORT=8080main.go — Server setup and entry point:
package main
import ( "log" "net/http" "os"
"github.com/keycardai/credentials-go/mcp")
func main() { zoneURL := os.Getenv("KEYCARD_ZONE_URL") port := os.Getenv("PORT") if port == "" { port = "8080" }
authProvider, err := mcp.NewAuthProvider( mcp.WithZoneURL(zoneURL), mcp.WithApplicationCredential(mcp.NewClientSecret( os.Getenv("KEYCARD_CLIENT_ID"), os.Getenv("KEYCARD_CLIENT_SECRET"), )), ) if err != nil { log.Fatal(err) }
mux := http.NewServeMux()
mux.Handle("/.well-known/", mcp.AuthMetadataHandler( mcp.WithIssuer(zoneURL), mcp.WithScopesSupported([]string{"mcp:tools"}), mcp.WithResourceName("GitHub MCP Server"), ))
registerGitHubRoutes(mux, authProvider)
log.Printf("GitHub MCP Server running on http://localhost:%s", port) log.Fatal(http.ListenAndServe(":"+port, mux))}tools.go — Each handler uses the grant middleware and extracts the exchanged token:
package main
import ( "fmt" "io" "net/http"
"github.com/keycardai/credentials-go/mcp")
const githubAPI = "https://api.github.com"
func githubRequest(r *http.Request, ac *mcp.AccessContext, method, path string) (*http.Response, error) { token, _ := ac.Access(githubAPI) req, err := http.NewRequestWithContext(r.Context(), method, githubAPI+path, nil) if err != nil { return nil, err } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) req.Header.Set("Accept", "application/vnd.github+json") return http.DefaultClient.Do(req)}
func registerGitHubRoutes(mux *http.ServeMux, authProvider *mcp.AuthProvider) { listRepos := mcp.RequireBearerAuth( mcp.WithRequiredScopes("mcp:tools"), )(authProvider.Grant(githubAPI)( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ac := mcp.AccessContextFromRequest(r) if ac.HasErrors() { http.Error(w, "Token exchange failed", http.StatusBadGateway) return }
resp, err := githubRequest(r, ac, "GET", "/user/repos?per_page=30&sort=updated") if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json") io.Copy(w, resp.Body) }), ))
mux.Handle("GET /api/repos", listRepos)
// Additional handlers follow the same pattern: grant → access context → check errors → call API // See full examples: https://github.com/keycardai/credentials-go/tree/main/examples}Test It
Section titled “Test It”-
Start your server
-
Connect from Cursor — add the MCP server URL to your Cursor MCP settings and complete the OAuth flow
-
Try these prompts:
- “List my GitHub repos”
- “Show open PRs on
owner/repo” - “Create an issue on
owner/repotitled ‘Bug: login broken’”
-
Verify in Audit Logs
In Keycard Console, navigate to Audit Logs to see the full flow:
Event Description users:authenticateUser logged in via Keycard users:authorizeUser authorized access to the MCP server credentials:issueAccess token issued — shows the identity chain (user + application) Each
credentials:issueevent includes an identity chain showing both the user identity (e.g.,alice@acme.com) and the application identity (e.g.,GitHub), so you can trace exactly which user triggered which API call through which application.
Google Workspace MCP Server
Section titled “Google Workspace MCP Server”Build an MCP server with Calendar and Drive tools that let AI agents manage a user’s Google Workspace on their behalf.
Keycard Setup
Section titled “Keycard Setup”-
Add Google from Resource Catalog
In Keycard Console, navigate to Resource Catalog and add Google Calendar and Google Drive. This automatically creates the Google OAuth provider and API resources.
-
Set up the Google OAuth App callback
- Go to Zone Settings in Keycard Console and copy the OAuth2 Redirect URL
- In Google Cloud Console → APIs & Services → Credentials, create or edit your OAuth 2.0 Client ID
- Add the Keycard redirect URL to Authorized redirect URIs
- Ensure the Calendar API and Drive API are enabled under APIs & Services → Enabled APIs
-
Register your MCP server resource
Navigate to Resources → Create Resource:
Field Value Resource Name Google Workspace MCP Server Resource Identifier http://localhost:8000/mcpCredential Provider Zone Provider -
Create an Application
Navigate to Applications → Create Application:
Field Value Provided Resource Google Workspace MCP Server (your MCP server) Dependencies Google Calendar API, 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 difference is the API URL you pass to grant() and the API calls you make with the token.
pip install keycardai-mcp-fastmcp fastmcp httpxThe server setup is identical to GitHub — just change the server name. The tools file uses the same grant pattern with https://www.googleapis.com:
GOOGLE_API = "https://www.googleapis.com"
def register_tools(mcp: FastMCP, auth_provider: AuthProvider): @mcp.tool() @auth_provider.grant(GOOGLE_API) async def list_calendar_events(ctx: Context, calendar_id: str = "primary") -> dict: """List events from a Google Calendar.""" access_context: AccessContext = ctx.get_state("keycardai") token = access_context.access(GOOGLE_API).access_token
# Use token to call Google Calendar API async with httpx.AsyncClient() as client: response = await client.get( f"{GOOGLE_API}/calendar/v3/calendars/{calendar_id}/events", headers={"Authorization": f"Bearer {token}"}, params={"singleEvents": "true", "orderBy": "startTime"}, ) # ... process response
@mcp.tool() @auth_provider.grant(GOOGLE_API) async def list_drive_files(ctx: Context, q: str | None = None) -> dict: """List or search files in Google Drive.""" access_context: AccessContext = ctx.get_state("keycardai") token = access_context.access(GOOGLE_API).access_token # Use token to call Google Drive APISee the full example with all tools, error handling, and Google Workspace file export logic: Python SDK examples
npm install @keycardai/mcp @modelcontextprotocol/sdk expressnpm install -D typescript @types/expressThe server setup is identical to GitHub — just change the server name. The tools file uses the same grant pattern with https://www.googleapis.com:
const GOOGLE_API = "https://www.googleapis.com";
export function registerGoogleRoutes(app: Express, authProvider: AuthProvider) { app.get("/api/calendar/events", authProvider.grant(GOOGLE_API), async (req, res) => { const { accessContext } = req as DelegatedRequest; if (accessContext.hasErrors()) { res.status(502).json({ error: "Token exchange failed" }); return; } const token = accessContext.access(GOOGLE_API).accessToken;
// Use token to call Google Calendar API const response = await fetch( `${GOOGLE_API}/calendar/v3/calendars/primary/events?singleEvents=true&orderBy=startTime`, { headers: { Authorization: `Bearer ${token}` } }, ); // ... process response });
app.get("/api/drive/files", authProvider.grant(GOOGLE_API), async (req, res) => { // Same pattern: grant → accessContext → token → API call });}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/credentials-go/mcpThe server setup is identical to GitHub — just change the server name. The tools file uses the same grant pattern with https://www.googleapis.com:
const googleAPI = "https://www.googleapis.com"
func registerGoogleRoutes(mux *http.ServeMux, authProvider *mcp.AuthProvider) { listEvents := mcp.RequireBearerAuth( mcp.WithRequiredScopes("mcp:tools"), )(authProvider.Grant(googleAPI)( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ac := mcp.AccessContextFromRequest(r) token, _ := ac.Access(googleAPI)
// Use token to call Google Calendar API req, _ := http.NewRequestWithContext(r.Context(), "GET", googleAPI+"/calendar/v3/calendars/primary/events?singleEvents=true&orderBy=startTime", nil) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } defer resp.Body.Close() // ... process response }), ))
listFiles := mcp.RequireBearerAuth( mcp.WithRequiredScopes("mcp:tools"), )(authProvider.Grant(googleAPI)( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ac := mcp.AccessContextFromRequest(r) token, _ := ac.Access(googleAPI) // Use token to call Google Drive API _ = token }), ))
mux.Handle("GET /api/calendar/events", listEvents) mux.Handle("GET /api/drive/files", listFiles)}See the full example with all tools, error handling, and Google Workspace file export logic: Go SDK examples
Test It
Section titled “Test It”-
Start your server
-
Connect from Cursor and complete the OAuth flow — you will be prompted to authorize Google Calendar and Drive access
-
Try these prompts:
- “Show my calendar events for this week”
- “List my recent Drive files”
- “Get the content of the file named ‘Meeting Notes’”
-
Verify in Audit Logs
Check Keycard Console Audit Logs — you should see the same
users:authenticate,users:authorize, andcredentials:issueevents, with the identity chain showing your user and the Google application.
The Pattern
Section titled “The Pattern”Every third-party integration follows the same steps:
- Add from Resource Catalog (or manually create the provider and resource for any OAuth 2.0 API)
- Register your MCP server — set Credential Provider to Zone Provider
- Create an Application — set your MCP server as the Provided Resource and external APIs as Dependencies
- Generate client credentials — Client ID + Client Secret for your application
- Use the grant pattern in code:
@auth_provider.grant("https://api.example.com")async def my_tool(ctx: Context): access_context: AccessContext = ctx.get_state("keycardai") token = access_context.access("https://api.example.com").access_token # Call API with tokenapp.get("/api/endpoint", authProvider.grant("https://api.example.com"), async (req, res) => { const { accessContext } = req as DelegatedRequest; const token = accessContext.access("https://api.example.com").accessToken; // Call API with token});handler := authProvider.Grant("https://api.example.com")( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ac := mcp.AccessContextFromRequest(r) token, _ := ac.Access("https://api.example.com") // Call API with token.AccessToken }),)This works for any OAuth 2.0 provider — Slack, Linear, Notion, and more.
Production Notes
Section titled “Production Notes”When deploying to production:
- Update resource identifiers in Keycard Console to your production URLs (must use HTTPS)
- Update OAuth redirect URIs in GitHub/Google to match your production Keycard zone
- Set environment variables securely — never commit client secrets to source control
- Token caching and refresh is handled automatically by Keycard
Troubleshooting
Section titled “Troubleshooting”Token Exchange Fails
Section titled “Token Exchange Fails”Symptom: access_context.has_errors() returns True (Python) / accessContext.hasErrors() returns true (TypeScript) / ac.HasErrors() returns true (Go)
- Verify the external API resource is added as a dependency on your Application
- Check that the OAuth provider credentials (in Resource Catalog) are correct
- Ensure the user has completed the OAuth consent flow for the external API
- Confirm your MCP server is reachable at the registered resource identifier URL
Invalid or Expired Token
Section titled “Invalid or Expired Token”Symptom: External API returns 401 Unauthorized
- Token refresh is automatic — if this persists, check provider configuration
- Verify the scopes in Keycard Console match what the API requires
- The user may need to re-authorize if they revoked access
Scope Mismatch
Section titled “Scope Mismatch”Symptom: External API returns 403 Forbidden or missing data
- Ensure scopes in Keycard Console match what the API requires
- For Google: verify the required APIs are enabled in Google Cloud Console
- User may need to disconnect and reconnect to pick up new scopes