---
title: GitHub | Keycard
description: 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.

Note

This guide assumes you’ve completed the [delegated access setup](/guides/delegated-access/index.md). If you haven’t, start there.

## GitHub Setup

1. **Add GitHub from Resource Catalog**

   In [Keycard Console](https://console.keycard.ai), navigate to **Resource Catalog** and add **GitHub**. This automatically creates the GitHub OAuth provider and GitHub API resource.

   Note

   The Resource Catalog provides one-click setup for popular APIs. For APIs not in the catalog, you can [manually create providers and resources](/getting-started/core-concepts#resources/index.md) in Keycard Console — any OAuth 2.0 provider works.

2. **Set up the GitHub OAuth App callback**

   1. Go to **Zone Settings** in Keycard Console and copy the **OAuth2 Redirect URL**
   2. In [GitHub Developer Settings](https://github.com/settings/developers), create or edit your **OAuth App**
   3. Set the **Authorization callback URL** to the Keycard redirect URL

3. **Register your MCP server resource**

   Navigate to **Resources** → **Create Resource**:

   | Field                   | Value                       |
   | ----------------------- | --------------------------- |
   | **Resource Name**       | GitHub MCP Server           |
   | **Resource Identifier** | `http://localhost:8000/mcp` |
   | **Credential Provider** | Zone Provider               |

   Note

   The resource identifier must match the URL where your MCP server is reachable.

4. **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

The server is split into two files: setup and tools. The setup file configures Keycard authentication; the tools file contains your API wrappers.

- [Python](#tab-panel-117)
- [TypeScript](#tab-panel-118)
- [Go](#tab-panel-119)

Install dependencies:

Terminal window

```
pip install keycardai-mcp-fastmcp fastmcp httpx
```

Create a `.env` file:

Terminal window

```
KEYCARD_ZONE_ID=your-zone-id
MCP_SERVER_URL=http://localhost:8000
KEYCARD_CLIENT_ID=your-client-id
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.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 httpx
from 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/examples
```

Install dependencies:

Terminal window

```
npm install @keycardai/mcp @modelcontextprotocol/sdk express
npm install -D typescript @types/express
```

Create a `.env` file:

Terminal window

```
KEYCARD_ZONE_URL=https://your-zone.keycard.cloud
KEYCARD_CLIENT_ID=your-client-id
KEYCARD_CLIENT_SECRET=your-client-secret
PORT=8080
```

**`server.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:

Terminal window

```
go mod init github-mcp-server
go get github.com/keycardai/credentials-go/mcp
```

Create a `.env` file:

Terminal window

```
KEYCARD_ZONE_URL=https://your-zone.keycard.cloud
KEYCARD_CLIENT_ID=your-client-id
KEYCARD_CLIENT_SECRET=your-client-secret
PORT=8080
```

**`main.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

1. **Start your server**

2. **Connect from Cursor** — add the MCP server URL to your Cursor MCP settings and complete the OAuth flow

3. **Try these prompts:**

   - “List my GitHub repos”
   - “Show open PRs on `owner/repo`”
   - “Create an issue on `owner/repo` titled ‘Bug: login broken’”

4. **Verify in Audit Logs**

   In Keycard Console, navigate to **Audit Logs** to see the full flow:

   | Event                | Description                                                         |
   | -------------------- | ------------------------------------------------------------------- |
   | `users:authenticate` | User logged in via Keycard                                          |
   | `users:authorize`    | User authorized access to the MCP server                            |
   | `credentials:issue`  | Access token issued — shows the identity chain (user + application) |

   Each `credentials:issue` event 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.
