GitHub
Build an MCP server with GitHub API tools using Keycard delegated access
Build an MCP server with repository-focused tools that let AI agents interact with GitHub on behalf of users.
GitHub Setup
Section titled “GitHub 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 = await 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.