AWS Bedrock MCP Server for GitHub Small Business DevOps
Give your developers an MCP server that lets AI agents query GitHub repositories, issues, and pull requests using natural language through AWS Bedrock.
Small dev teams waste time navigating GitHub's UI to gather information across repos, losing context when switching between tasks and needing answers from past pull requests and issues.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This tutorial walks through building an MCP (Model Context Protocol) server that connects AWS Bedrock to GitHub. You’ll create a server that exposes four GitHub tools — search repositories, list organization repos, list issues, and get pull requests — to AI agents via Bedrock’s Converse API. A firewall layer blocks destructive operations, Langfuse traces every LLM call, and the whole thing runs on Express 5 inside a Next.js 16 project. This is for small DevOps teams who want AI agents that can safely read their GitHub data.
Prerequisites
Node.js 22+ and pnpm 10
An AWS account with Bedrock access (Claude 3 Sonnet or equivalent model)
A GitHub personal access token (classic, with repo and read:org scopes)
A Langfuse account for observability (free tier works)
Familiarity with TypeScript, the Model Context Protocol, and Express.js routing
Step 1: Scaffold the project and install dependencies
Create a new directory, initialize a pnpm project, and set up the package.json:
import { Logger } from "@reaatech/tool-use-firewall-core";export default new Logger("aws-bedrock-mcp-server");
Expected output: The config module reads envConfig.PORT, envConfig.LOG_LEVEL, etc. from the @reaatech/mcp-server-core package, which automatically loads dotenv and validates the required variables.
Step 5: Build the GitHub service layer
Create src/services/github.ts. This wraps Octokit REST API calls. Every method is an async function that delegates to the @octokit/rest client:
Expected output: Each function calls the Octokit REST API with typed parameters. The Octokit client is constructed once with the token from config.
Step 6: Define MCP tools with Zod schemas
Each tool is defined using defineTool from @reaatech/mcp-server-tools. The inputSchema is a Zod object that validates inbound parameters, and the handler calls the appropriate GitHub service function.
search-repos.tool.ts
Create src/tools/search-repos.tool.ts:
ts
import { defineTool } from "@reaatech/mcp-server-tools";import { z } from "zod";import { errorResponse, textContent } from "@reaatech/mcp-server-core";import { searchRepositories } from "../services/github.js";export default defineTool({ name: "search-repos", description: "Search GitHub repositories by query", inputSchema: z.object({ query: z.string().describe("Search query"), perPage: z.number().optional().describe("Results per page"), }), handler: async (args) => { try { const items = await searchRepositories(args.query as string, args.perPage as number | undefined); return { content: [textContent(JSON.stringify(items))] }; } catch (error) { return errorResponse(error instanceof Error ? error.message : String(error)); } },});
list-repos.tool.ts
Create src/tools/list-repos.tool.ts:
ts
import { defineTool } from "@reaatech/mcp-server-tools";import { z } from "zod";import { errorResponse, textContent } from "@reaatech/mcp-server-core";import { listOrganizationRepos } from "../services/github.js";export default defineTool({ name: "list-repos", description: "List repositories for an organization or user", inputSchema: z.object({ org: z.string().describe("Organization name"), type: z.enum(["forks", "public", "all", "private", "sources", "member"]).optional().describe("Repository type"), }), handler: async (args) => { try { const data = await listOrganizationRepos(args.org as string, args.type as "forks" | "public" | "all" | "private" | "sources" | "member"); return { content: [textContent(JSON.stringify(data))] }; } catch (error) { return errorResponse(error instanceof Error ? error.message : String(error)); } },});
list-issues.tool.ts
Create src/tools/list-issues.tool.ts:
ts
import { defineTool } from "@reaatech/mcp-server-tools";import { z } from "zod";import { errorResponse, textContent } from "@reaatech/mcp-server-core";import { listRepositoryIssues } from "../services/github.js";export default defineTool({ name: "list-issues", description: "List issues for a repository", inputSchema: z.object({ owner: z.string().describe("Repository owner"), repo: z.string().describe("Repository name"), state: z.enum(["open", "closed", "all"]).optional().describe("Issue state"), }), handler: async (args) => { try { const data = await listRepositoryIssues(args.owner as string, args.repo as string, args.state as "open" | "closed" | "all"); return { content: [textContent(JSON.stringify(data))] }; } catch (error) { return errorResponse(error instanceof Error ? error.message : String(error)); } },});
get-pull-request.tool.ts
Create src/tools/get-pull-request.tool.ts:
ts
import { defineTool } from "@reaatech/mcp-server-tools";import { z } from "zod";import { errorResponse, textContent } from "@reaatech/mcp-server-core";import { getPullRequest } from "../services/github.js";export default defineTool({ name: "get-pull-request", description: "Get a pull request by number", inputSchema: z.object({ owner: z.string().describe("Repository owner"), repo: z.string().describe("Repository name"), pullNumber: z.number().describe("Pull request number"), }), handler: async (args) => { try { const data = await getPullRequest(args.owner as string, args.repo as string, args.pullNumber as number); return { content: [textContent(JSON.stringify(data))] }; } catch (error) { return errorResponse(error instanceof Error ? error.message : String(error)); } },});
Tool registry
Create src/tools/index.ts to register all tools in one place:
ts
import { registerTool, getTools, clearTools } from "@reaatech/mcp-server-tools";import searchReposTool from "./search-repos.tool.js";import listReposTool from "./list-repos.tool.js";import listIssuesTool from "./list-issues.tool.js";import getPullRequestTool from "./get-pull-request.tool.js";export function registerAllTools() { clearTools(); registerTool(searchReposTool); registerTool(listReposTool); registerTool(listIssuesTool); registerTool(getPullRequestTool); const count = getTools().length; if (count !== 4) { throw new Error(`Expected 4 tools to be registered, got ${String(count)}`); }}export function getAllTools() { return getTools();}
Expected output: All four tools are registered. Each tool has a unique name, a human-readable description, and a Zod input schema that Bedrock’s Converse API will use to generate tool call arguments.
Step 7: Build the firewall policies
The firewall intercepts every tool call and blocks destructive operations before they reach GitHub. Create src/firewall/policies.ts:
ts
import { PolicyViolationError, redact, safeRegExp } from "@reaatech/tool-use-firewall-core";export function denyDeleteToolNames(toolName: string): void { const pattern = safeRegExp("\\b(delete|remove|destroy|purge)\\b"); if (pattern.test(toolName)) { throw new PolicyViolationError({ message: `Tool '${toolName}' blocked: destructive operations denied`, }); }}export function denyAdminEscalation(toolName: string, args: Record<string, unknown>): void { if (args.isAdmin || args.force || args.skipChecks) { throw new PolicyViolationError({ message: "Admin-level operation blocked by firewall", }); }}export function validateToolCall(toolName: string, args: Record<string, unknown>): void { denyDeleteToolNames(toolName); denyAdminEscalation(toolName, args);}export function createFirewallMiddleware() { return { execute(context: { toolName: string; arguments: Record<string, unknown> }) { try { const safeArgs = redact(context.arguments) as Record<string, unknown>; validateToolCall(context.toolName, safeArgs); return Promise.resolve({ action: "CONTINUE" as const }); } catch (error) { if (error instanceof PolicyViolationError) { return Promise.resolve({ action: "BLOCK" as const, reason: error.message }); } throw error; } }, };}
Expected output: The firewall returns { action: "CONTINUE" } for safe tool names like list-repos. It returns { action: "BLOCK", reason: "..." } for names matching delete, remove, destroy, or purge, and for calls that pass isAdmin, force, or skipChecks arguments.
Step 8: Create the Langfuse observability client
Create src/lib/langfuse.ts to trace every Bedrock interaction:
Expected output: The client reads credentials from environment variables. Every trace and generation in the Bedrock module will report through this client.
Step 9: Build the AWS Bedrock integration
Create src/llm/bedrock.ts. This is the core LLM integration — it sends messages to Bedrock, relays registered tool definitions to the model, and loops back tool results when the model requests a tool use:
ts
import { BedrockRuntimeClient, ConverseCommand, ConverseStreamCommand,} from "@aws-sdk/client-bedrock-runtime";import type { ToolUseBlock, Tool, ContentBlock, ToolResultBlock } from "@aws-sdk/client-bedrock-runtime";import { getTool, getTools } from "@reaatech/mcp-server-tools";import { z } from "zod";import langfuse from "../lib/langfuse.js";const client = new BedrockRuntimeClient({ region: process.env.AWS_REGION ?? "us-east-1" });export async function converse(params: { modelId: string; messages:
Expected output: Three exported functions — converse (plain text), converseWithTools (tool-calling loop with follow-up), and converseStream (streaming). Each wraps a Converse API call with Langfuse tracing.
Step 10: Build the Express MCP server
Create src/server.ts. This wires together the MCP SDK, the Express app, the auth middleware, the firewall, and the tool handlers:
ts
import express from "express";import type { Request, Response } from "express";import { authMiddleware } from "@reaatech/mcp-server-auth";import { getTools, getTool } from "@reaatech/mcp-server-tools";import { envConfig, APP_VERSION, SERVER_INFO } from "@reaatech/mcp-server-core";import { createFirewallMiddleware } from "./firewall/policies.js";import { registerAllTools } from "./tools/index.js";import type { ToolContext } from "@reaatech/mcp-server-core";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import langfuse from "./lib/langfuse.js";interface
Expected output: The server exports startServer() and stopServer(). It creates an Express 5 app that accepts JSON-RPC 2.0 requests at POST /mcp and responds to tools/list and tools/call. The /health endpoint returns server status. Auth middleware protects every route. The firewall runs before every tool call.
Step 11: Create the entry point
Create src/index.ts as the application entry point that boots the server and handles graceful shutdown:
Expected output: Running the app starts the server. The entry point is loaded by Next.js at build time — the server boots as part of the Next.js instrumentation lifecycle. When the process receives SIGTERM or SIGINT, it shuts down gracefully, ensuring Langfuse flushes pending traces before exiting.
Step 12: Write the test suite
The project includes a comprehensive test suite covering the server, MCP protocol, firewall, Bedrock integration, GitHub service, tools, and config. Below is a representative excerpt from the server test that exercises the MCP JSON-RPC protocol end to end. The full test suite contains 11 test files — here’s tests/server.test.ts:
pnpm vitest run --coverage --reporter=json --outputFile=vitest-report.json
Expected output: All tests pass across all 11 test files — covering the MCP JSON-RPC handler, firewall policies, Bedrock integration, tool definitions, GitHub service layer, config fallbacks, and full server lifecycle including graceful shutdown.
Next steps
Add a GitHub webhook handler so the MCP server can listen for push, issue, and PR events and push them into Bedrock’s context automatically.
Extend the tool set with create-issue, list-workflows, or get-commit tools — many more Octokit methods are a single defineTool call away.
Deploy behind an API gateway like AWS API Gateway or NGINX with rate limiting, and wire a custom domain with HTTPS termination.
Integrate with Claude Desktop or any MCP client by pointing it at http://localhost:8080/mcp and using the API key from API_KEY.