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”If your Zone already has a GitHub Provider (the Quickstart creates one), skip to step 3.
-
Copy your Keycard redirect URL
In Keycard Console, open Settings → Connection and copy the Redirect URL. You will need this when creating the GitHub OAuth App.
-
Create a GitHub OAuth App
In GitHub Developer Settings, create a new OAuth App and set the Redirect URI (older forms call it Authorization callback URL) to the redirect URL you copied from Keycard. Once created, note the Client ID and generate a Client Secret.
-
Add GitHub API to Keycard
In Keycard Console, open Resources → Add Resource → Explore Resources, search for GitHub, and choose GitHub API. The setup screen offers two ways to connect GitHub: a personal access token or an OAuth App. Paste the Client ID and Client Secret from step 2, leave Personal Access Token empty, and click Protect GitHub API. This creates the GitHub Provider and the GitHub API Resource.
Leave the token empty for per-user access. With an OAuth App, each User connects their own GitHub account and your tools call GitHub as them; with a token, every call acts as the GitHub user who created it, no matter who signed in, and if you fill in both, the token wins. Act as the user who asked covers why that matters. If your Zone already has a GitHub Provider, the screen shows only the Personal Access Token field; leave it empty and click Protect GitHub API. Keycard reuses that Provider, and if GitHub API is already in Resources, opens it instead of adding a second one.
-
Register your MCP server Resource
Navigate to Resources → Add Resource → Add Manually:
Field Value Name GitHub MCP Server Identifier http://localhost:8000/mcpCredential Provider Zone Provider -
Create an Application
Navigate to Applications → Add Application, set Name and Identifier (for example
github-mcp-server), and click Create application.Then, on the application details page:
- On the Provides tab, click Add provided resource and select your GitHub MCP Server.
- On the Dependencies tab, click Add dependency and select 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-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>server.py: Server setup and entry point:
"""GitHub MCP Server with Keycard Delegated Access."""
import os
from fastmcp import FastMCP
from keycardai.fastmcp import AuthProvider, ClientSecret
auth_provider = AuthProvider( zone_url=os.environ["KEYCARD_ISSUER"], mcp_server_name="GitHub MCP Server", mcp_server_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.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/fastmcp/examples/delegated_accessInstall dependencies:
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>server.ts: Server setup and entry point:
import express from "express";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";import { mcpAuthMetadataRouter } from "@keycardai/mcp/server/auth/router";import { requireBearerAuth } from "@keycardai/mcp/server/auth/middleware/bearerAuth";import { AuthProvider, type DelegatedRequest } from "@keycardai/mcp/server/auth/provider";import { ClientSecret } from "@keycardai/mcp/server/auth/credentials";import { GITHUB_API, registerGitHubTools } from "./tools";
const ZONE_URL = process.env.KEYCARD_ISSUER ?? "<keycard-issuer-url>"; // from Settings → Connectionconst 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 ?? 8000);
// Auth provider for token exchangeconst authProvider = new AuthProvider({ zoneUrl: ZONE_URL, applicationCredential: new ClientSecret(CLIENT_ID, CLIENT_SECRET),});
const app = express();app.use(express.json());
// OAuth metadata endpoints (unauthenticated)app.use( mcpAuthMetadataRouter({ oauthMetadata: { issuer: ZONE_URL }, resourceName: "GitHub MCP Server", }),);
// Chain: bearer auth → grant → MCP handlerapp.post( "/mcp", requireBearerAuth({ issuers: ZONE_URL, // Bind accepted tokens to this server's Resource identifier. audiences: `http://localhost:${PORT}/mcp`, }), authProvider.grant(GITHUB_API), async (req, res) => { const server = new McpServer({ name: "GitHub MCP Server", version: "1.0.0" }); registerGitHubTools(server, (req as DelegatedRequest).accessContext);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport); await transport.handleRequest(req, res, req.body); },);
// Stateless server: no SSE stream and no session to endapp.get("/mcp", (_req, res) => { res.set("Allow", "POST").status(405).end(); });app.delete("/mcp", (_req, res) => { res.set("Allow", "POST").status(405).end(); });
app.listen(PORT, () => { console.log(`GitHub MCP Server running on http://localhost:${PORT}`);});tools.ts: Each tool reads the exchanged token from the access context:
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import type { AccessContext } from "@keycardai/mcp/server/auth/provider";
export 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 registerGitHubTools(server: McpServer, accessContext: AccessContext) { server.tool( "list_repos", "List the authenticated user's repositories.", async () => { if (accessContext.hasErrors()) { return { content: [{ type: "text", text: `Token exchange failed: ${JSON.stringify(accessContext.getErrors())}` }], isError: true, }; } const token = accessContext.access(GITHUB_API).accessToken; const repos = await githubRequest(token, "/user/repos?per_page=30&sort=updated"); const result = { count: repos.length, repositories: repos.map((r: any) => ({ name: r.name, full_name: r.full_name, html_url: r.html_url, })), }; return { content: [{ type: "text", text: JSON.stringify(result) }] }; }, );
// Additional tools 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/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>main.go: Server setup and entry point:
// GitHub MCP Server with Keycard delegated access.package main
import ( "log" "net/http" "os"
"github.com/keycardai/go-sdk/mcp" "github.com/keycardai/go-sdk/oauth" "github.com/mark3labs/mcp-go/server")
func main() { issuer := os.Getenv("KEYCARD_ISSUER") serverURL := os.Getenv("MCP_SERVER_URL") if serverURL == "" { serverURL = "http://localhost:8000" }
credential, err := mcp.NewClientSecret( os.Getenv("KEYCARD_CLIENT_ID"), os.Getenv("KEYCARD_CLIENT_SECRET"), ) if err != nil { log.Fatal(err) }
// Auth provider for token exchange authProvider, err := mcp.NewAuthProvider( mcp.WithZoneURL(issuer), mcp.WithApplicationCredential(credential), ) if err != nil { log.Fatal(err) }
// Verifier for inbound bearer tokens, bound to this server's Resource identifier verifier, err := mcp.NewZoneTokenVerifier(issuer, oauth.WithAudiences(serverURL+"/mcp")) if err != nil { log.Fatal(err) }
s := server.NewMCPServer("GitHub MCP Server", "1.0.0") registerTools(s)
httpMux := http.NewServeMux()
// Serve OAuth metadata endpoints httpMux.Handle("/.well-known/", mcp.AuthMetadataHandler( mcp.WithIssuer(issuer), mcp.WithScopesSupported([]string{"mcp:tools"}), mcp.WithResourceName("GitHub MCP Server"), ))
// Chain: bearer auth → grant → MCP handler httpMux.Handle("/mcp", mcp.RequireBearerAuth(verifier, mcp.WithRequiredScopes("mcp:tools"), )(authProvider.Grant([]string{githubAPI})( server.NewStreamableHTTPServer(s), )))
log.Println("GitHub MCP Server running on http://localhost:8000") log.Fatal(http.ListenAndServe(":8000", httpMux))}tools.go: Each tool reads the exchanged token from the access context:
package main
import ( "context" "encoding/json" "fmt" "io" "net/http"
"github.com/keycardai/go-sdk/mcp" mcpgo "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server")
const githubAPI = "https://api.github.com"
// githubRequest makes an authenticated request to the GitHub API.func githubRequest(ctx context.Context, token, method, path string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, method, githubAPI+path, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, fmt.Errorf("GitHub API error: %d", resp.StatusCode) } return io.ReadAll(resp.Body)}
func registerTools(s *server.MCPServer) { s.AddTool( mcpgo.NewTool("list_repos", mcpgo.WithDescription("List the authenticated user's repositories."), ), func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { ac := mcp.AccessContextFromContext(ctx) if ac.HasErrors() { return mcpgo.NewToolResultError("Token exchange failed"), nil } token, err := ac.Access(githubAPI) if err != nil { return mcpgo.NewToolResultError(err.Error()), nil }
body, err := githubRequest(ctx, token.AccessToken, "GET", "/user/repos?per_page=30&sort=updated") if err != nil { return mcpgo.NewToolResultError(err.Error()), nil }
var repos []struct { Name string `json:"name"` FullName string `json:"full_name"` HTMLURL string `json:"html_url"` } if err := json.Unmarshal(body, &repos); err != nil { return mcpgo.NewToolResultError(err.Error()), nil } result, _ := json.Marshal(map[string]any{"count": len(repos), "repositories": repos}) return mcpgo.NewToolResultText(string(result)), nil }, )
// Additional tools follow the same pattern: grant → access context → check errors → call API // See full examples: https://github.com/keycardai/go-sdk/tree/main/examples}Install dependencies:
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>config.ru: Server setup and entry point:
# GitHub MCP Server with Keycard delegated access.
require "json"require "keycardai/mcp"require "mcp"
require_relative "tools"
issuer = ENV.fetch("KEYCARD_ISSUER")
auth_provider = Keycardai::MCP::AuthProvider.new( zone_url: issuer, client_id: ENV.fetch("KEYCARD_CLIENT_ID"), client_secret: ENV.fetch("KEYCARD_CLIENT_SECRET"), # Bind accepted tokens to this server's Resource identifier audiences: "http://localhost:8000/mcp",)
metadata = Keycardai::MCP::MetadataApp.new( issuer: issuer, resource_name: "GitHub MCP Server", scopes_supported: ["mcp:tools"],)
# The MCP endpoint reads the exchanged tokens from the Rack env and hands# them to the tools via server_context. The mcp gem's Streamable HTTP# transport acknowledges notifications with 202 and answers GET and DELETE.mcp_endpoint = lambda do |env| access_context = Keycardai::MCP.access_context(env) mcp_server = build_mcp_server(access_context) MCP::Server::Transports::StreamableHTTPTransport.new( mcp_server, stateless: true, enable_json_response: true, ).call(env)end
# Chain: bearer auth → grant → MCP handlerprotected_mcp = Keycardai::MCP::RequireBearerAuth.new( auth_provider.grant(GITHUB_API).new(mcp_endpoint), verifier: auth_provider.token_verifier, required_scopes: ["mcp:tools"],)
run lambda { |env| case env["PATH_INFO"] when "/.well-known/oauth-protected-resource" then metadata.call(env.merge("PATH_INFO" => "/.well-known/oauth-protected-resource/mcp")) when %r{\A/\.well-known/} then metadata.call(env) when "/mcp" then protected_mcp.call(env) else [404, { "content-type" => "application/json" }, ['{"error":"not_found"}']] end}tools.rb: Each tool reads the exchanged token from the access context:
require "json"require "net/http"
GITHUB_API = "https://api.github.com"
# Make an authenticated request to the GitHub APIdef github_request(token, path) uri = URI("#{GITHUB_API}#{path}") request = Net::HTTP::Get.new(uri) request["Authorization"] = "Bearer #{token}" request["Accept"] = "application/vnd.github+json"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) } raise "GitHub API error: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)end
def build_mcp_server(access_context) mcp_server = MCP::Server.new( name: "GitHub MCP Server", version: "1.0.0", server_context: { access_context: access_context }, )
mcp_server.define_tool( name: "list_repos", description: "List the authenticated user's repositories", input_schema: { properties: { per_page: { type: "integer" } }, required: [] }, ) do |per_page: 30, server_context:| context = server_context[:access_context] # access raises if the exchange for this resource failed token = context.access(GITHUB_API).access_token
repos = github_request(token, "/user/repos?per_page=#{per_page}&sort=updated") MCP::Tool::Response.new([{ type: "text", text: JSON.dump({ "count" => repos.length, "repositories" => repos.map { |r| r.slice("name", "full_name", "html_url") }, }), }]) end
# Additional tools follow the same pattern: grant → access_context → call API # See full examples: https://github.com/keycardai/ruby-sdk/tree/main/examples mcp_serverendTest 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": {"github-mcp": {"url": "http://localhost:8000/mcp"}}}Claude Code: run in your terminal:
Terminal window claude mcp add --transport http github-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 github-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. GitHub asks you to authorize only the first time; if you did the Quickstart, you’ve already granted it. -
Try these prompts:
- “List my GitHub repos”
- “Which of my GitHub repos did I update most recently?”
-
Verify in Audit Logs
In Keycard Console, navigate to Audit Log to see the full flow:
Event Description credentials:issueOne per hop (your agent’s token for the MCP server, then your Application’s token for GitHub API), each with an identity chain: your agent or Application, then you users:authenticateOnly if you signed in during this flow users:authorizeOnly when GitHub asked you to authorize; its Entity is GitHub API Each GitHub API
credentials:issueevent includes an identity chain showing both the user identity (e.g.,troy.barnes@acme.com) and the application identity (your Application, such as GitHub MCP Server), so you can trace exactly which user triggered which API call through which application.
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. - Google Workspace: the same server with Calendar and Drive tools.