β€’14 min readβ€’Engineeringβ€’Blog

How We Built an AI Blog Publisher Using MCP, AWS App Runner and GitHub API

In a single session we went from zero to a fully deployed MCP server that lets Claude β€” or any AI agent β€” draft, upload and publish blog articles to this site autonomously. Here is exactly what we built, every tool we used, and every CLI command that mattered.

What is the Model Context Protocol (MCP)?

MCP is an open standard introduced by Anthropic that lets AI assistants like Claude connect to external tools and data sources in a structured way. Instead of cramming instructions into a system prompt, you expose capabilities β€” called tools β€” as a server. Claude discovers what the tools do, decides when to call them, passes typed arguments, and acts on the results. Because MCP is transport-agnostic (it runs over stdio locally or HTTPS remotely), the same server works with Claude Code on your laptop and with Claude Teams in the cloud. Think of it as a universal plug socket for AI agents and the services they need to get things done.

1. The problem we were solving

InAgentic runs workshops on AI-native engineering. We wanted Claude β€” or any connected AI agent β€” to be able to write and publish a blog article to inagentic.ai/blog without a human touching a keyboard. The workflow needed two stages:

  1. Upload β€” create the article page and make it accessible at its URL, but keep it off the public listing so we can review it first.
  2. Publish β€” once approved, add a card to the blog index with title, summary and read-more link.

The site is a Next.js app deployed on AWS Amplify. Every merge to main on GitHub triggers a full rebuild and deploy. That gave us a clean integration point: if the MCP server commits files to GitHub, Amplify does the rest.

2. Architecture overview

The final system has four moving parts:

  • Claude Teams (or Claude Code) β€” calls the MCP tools.
  • MCP server on AWS App Runner β€” exposes upload_article, publish_article and list_articles over HTTPS.
  • GitHub API β€” the MCP server commits files directly to OxComply/oxcomply-app using a fine-grained personal access token.
  • AWS Amplify β€” detects the new commit, builds the Next.js app, and deploys the new page.

The key insight is that the MCP server never needs to know about Amplify. It just writes to GitHub; Amplify takes care of itself. This keeps the MCP server stateless and simple.

3. Building the MCP server

We used the official @modelcontextprotocol/sdk for TypeScript. The server supports two transport modes selected by the MCP_TRANSPORT environment variable:

  • stdio (default) β€” for local use with Claude Code and Claude Desktop. Reads/writes files directly on the filesystem.
  • http β€” for remote use with Claude Teams. Uses the StreamableHTTPServerTransport and the GitHub API for all file operations.

The three tools the server exposes:

upload_article

Takes a URL slug, title, description, excerpt, HTML content, date, reading time, category and keywords. Generates a complete app/blog/[slug]/page.tsx file with proper Next.js metadata, Tailwind prose styling, and a canonical URL pointing to inagentic.ai. Commits the file to GitHub and returns the preview URL. The article is live immediately but does not appear in the blog listing.

publish_article

Takes the slug and listing card details (title, excerpt, date, read time, category, optional featured flag). Reads app/blog/page.tsx from GitHub, inserts a new entry into the blogPosts array, and commits the updated file. Amplify picks up the commit and the card appears on the listing within minutes.

list_articles

Lists all blog slugs that exist as directories in app/blog/, cross-referenced against the listing file to show which are published and which are still drafts.

4. Using the GitHub API instead of the filesystem

The first version of the server wrote directly to the local filesystem β€” fine for local development but useless for a deployed server that has no access to the Next.js app source. The solution is the@octokit/rest GitHub API client.

Creating or updating a file is a single API call:

await octokit.repos.createOrUpdateFileContents({
  owner: 'OxComply',
  repo:  'oxcomply-app',
  path:  'app/blog/my-article/page.tsx',
  message: 'blog: add article "My Article"',
  content: Buffer.from(pageContent).toString('base64'),
  branch:  'main',
  // sha required when updating an existing file:
  sha: existingFileSha,
});

Reading a file returns both the content and its SHA, which you must pass back when updating β€” GitHub uses this to detect concurrent edits:

const { data } = await octokit.repos.getContent({
  owner: 'OxComply',
  repo:  'oxcomply-app',
  path:  'app/blog/page.tsx',
  ref:   'main',
});
const content = Buffer.from(data.content, 'base64').toString('utf-8');
const sha     = data.sha;

The GitHub token needs only Contents: Read and Write on the target repository. Create a fine-grained personal access token scoped to a single repo β€” never a classic token with broad permissions.

5. OAuth 2.0 with PKCE for Claude Teams

Claude Teams custom connectors use OAuth 2.0 Authorization Code flow with PKCE β€” not a simple API key header. When you click β€œconnect” in the Claude Teams connector UI, it redirects your browser to the server's /authorize endpoint:

GET /authorize
  ?response_type=code
  &client_id=filedone
  &redirect_uri=https://claude.ai/api/mcp/auth_callback
  &code_challenge=<base64url(SHA256(verifier))>
  &code_challenge_method=S256
  &state=<random>

Our server shows a simple HTML form asking for the API key. Once entered correctly it:

  1. Generates a random auth code and stores it in memory with the code challenge and an expiry.
  2. Redirects to redirect_uri?code=<code>&state=<state>.

Claude then exchanges the code at /oauth/token, providing the original code_verifier. The server verifies PKCE β€” base64url(SHA256(verifier)) === stored challenge β€” then returns the access token. All subsequent MCP calls carry Authorization: Bearer <token>.

The OAuth discovery document at /.well-known/oauth-authorization-server tells Claude where to find the authorize and token endpoints β€” Claude reads this automatically before starting the flow.

6. Docker and AWS ECR

The server is a Node.js 20 application containerised with a two-stage Dockerfile: a builder stage that compiles TypeScript, and a lean runtime stage that copies only the compiled output and production dependencies.

# Authenticate Docker with ECR
aws ecr get-login-password --region eu-west-1 --profile YOUR_PROFILE \
  | docker login --username AWS --password-stdin \
    YOUR_ACCOUNT_ID.dkr.ecr.eu-west-1.amazonaws.com

# Create the ECR repository (once)
aws ecr create-repository \
  --repository-name filedone-mcp \
  --region eu-west-1 --profile YOUR_PROFILE

# Build, tag and push
docker build -t filedone-mcp ./mcp-server
docker tag filedone-mcp:latest \
  YOUR_ACCOUNT_ID.dkr.ecr.eu-west-1.amazonaws.com/filedone-mcp:latest
docker push \
  YOUR_ACCOUNT_ID.dkr.ecr.eu-west-1.amazonaws.com/filedone-mcp:latest

To redeploy after a code change, repeat the build/tag/push steps then trigger a new deployment:

aws apprunner start-deployment \
  --service-arn YOUR_SERVICE_ARN \
  --region eu-west-1 --profile YOUR_PROFILE

7. Deploying to AWS App Runner

We chose App Runner over Lambda for two reasons:

  • No cold starts β€” App Runner keeps at least one instance warm. Lambda can add 1–3 seconds of latency on the first request after an idle period, which is noticeable when Claude is waiting on a tool call.
  • No payload limit anxiety β€” Lambda synchronous invocations are capped at 6 MB. Article HTML content stays well under that, but App Runner removes the concern entirely.

The cost at this traffic level is around $3/month (0.25 vCPU, 0.5 GB RAM).

Before creating the service, App Runner needs an IAM role to pull images from ECR:

# Create the ECR access role
aws iam create-role \
  --role-name AppRunnerECRAccessRole \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": { "Service": "build.apprunner.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }]
  }' --profile YOUR_PROFILE

# Attach the managed policy
aws iam attach-role-policy \
  --role-name AppRunnerECRAccessRole \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess \
  --profile YOUR_PROFILE

Then create the service with environment variables set at deploy time β€” no secrets baked into the image:

aws apprunner create-service \
  --region eu-west-1 --profile YOUR_PROFILE \
  --service-name filedone-mcp \
  --source-configuration '{
    "ImageRepository": {
      "ImageIdentifier": "YOUR_ACCOUNT_ID.dkr.ecr.eu-west-1.amazonaws.com/filedone-mcp:latest",
      "ImageConfiguration": {
        "Port": "3001",
        "RuntimeEnvironmentVariables": {
          "MCP_TRANSPORT":    "http",
          "BLOG_MCP_API_KEY": "YOUR_API_KEY",
          "GITHUB_TOKEN":     "YOUR_GITHUB_TOKEN",
          "GITHUB_REPO":      "OxComply/oxcomply-app",
          "GITHUB_BRANCH":    "main",
          "BLOG_BASE_URL":    "https://www.inagentic.ai"
        }
      },
      "ImageRepositoryType": "ECR"
    },
    "AutoDeploymentsEnabled": false,
    "AuthenticationConfiguration": {
      "AccessRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/AppRunnerECRAccessRole"
    }
  }' \
  --instance-configuration '{"Cpu":"0.25 vCPU","Memory":"0.5 GB"}' \
  --health-check-configuration '{"Protocol":"HTTP","Path":"/health"}'

8. Custom domain via Route 53

App Runner handles TLS certificate provisioning automatically when you associate a custom domain. It gives you three CNAME records to add for certificate validation, plus one CNAME to point the subdomain at the service.

# Associate the domain
aws apprunner associate-custom-domain \
  --service-arn YOUR_SERVICE_ARN \
  --domain-name mcp.inagentic.ai \
  --region eu-west-1 --profile YOUR_PROFILE

# Retrieve the DNS records App Runner needs
aws apprunner describe-custom-domains \
  --service-arn YOUR_SERVICE_ARN \
  --region eu-west-1 --profile YOUR_PROFILE

The output includes a CertificateValidationRecords array and a DNSTarget. Add all of them to Route 53 with aws route53 change-resource-record-sets or through the console. Certificate validation typically completes in under 10 minutes.

9. SEO: canonical URLs across two domains

The Next.js app serves both inagentic.ai and filedone.com. Blog content only belongs on InAgentic, but because it is a single codebase the pages are technically reachable on both domains. Duplicate content across domains splits PageRank and can suppress rankings on both.

The fix is a canonical tag in every blog page's metadata:

export const metadata: Metadata = {
  title: 'My Article | InAgentic',
  alternates: {
    canonical: 'https://www.inagentic.ai/blog/my-article',
  },
};

Next.js renders this as <link rel="canonical" href="..." /> in the document head. Google reads it and consolidates all ranking signals to the canonical URL regardless of which domain served the page. The MCP server template includes this automatically for every generated article.

The blog nav link is already only shown on the InAgentic domain β€” the Navigation component checks a domain cookie β€” so FileDone visitors never see a path to the blog. Canonical is a belt-and-braces protection for direct URL access and crawlers.

10. What we learned

Claude Teams uses Authorization Code + PKCE, not client credentials

The connector setup form in Claude Teams shows β€œOAuth Client ID” and β€œOAuth Client Secret” fields β€” which looks like client credentials. It is not. Claude Teams performs a full browser-based Authorization Code flow with PKCE. The server must implement /authorize, /oauth/token and/.well-known/oauth-authorization-server. The client ID and secret in the form are only used after the user approves the connection.

AWS CLI version matters

App Runner was added to the AWS CLI in v2.1+. The system had v2.0.30 installed (from the official pkg installer in 2022) while Homebrew had already upgraded to v2.34. The old binary at/usr/local/bin/aws was a symlink to the pkg installer version and shadowed the Homebrew one. Check with aws --version and use the full path to the Homebrew binary if needed:

/usr/local/opt/awscli/bin/aws apprunner create-service ...

Amplify build failures are silent unless you check the logs

When the first MCP-generated article was uploaded, Amplify failed silently β€” the article existed in GitHub but the page returned 404. The cause was a strict TypeScript union type on thePageViewTracker component that only accepted a hardcoded list of page names. The generated article used a dynamic name (inagentic_blog_european-cities-ai-disruption-ranking) that wasn't in the list.

Fetch the build logs directly from S3 when a build fails:

aws amplify get-job \
  --app-id YOUR_APP_ID \
  --branch-name main \
  --job-id YOUR_JOB_ID \
  --region eu-west-2 --profile YOUR_PROFILE \
  --query 'job.steps[?status==`FAILED`].logUrl'

GitHub API commits trigger Amplify automatically

No webhook configuration needed. Amplify watches the connected GitHub branch and triggers a build on every push β€” including pushes made by API. This means an AI agent calling upload_article will automatically trigger a deployment without any additional orchestration.

Two-stage Docker builds keep images small

TypeScript devDependencies (the compiler, type definitions) are not needed at runtime. A two-stage Dockerfile compiles in the first stage and copies only dist/ and productionnode_modules to the final image. This cut our image size by roughly 60% compared to a single-stage build.


Technologies used

  • Model Context Protocol (MCP) β€” @modelcontextprotocol/sdk v1.15
  • AWS App Runner β€” container hosting, auto-HTTPS, no cold starts
  • AWS ECR β€” private Docker image registry
  • AWS Route 53 β€” DNS management and certificate validation records
  • GitHub API β€” @octokit/rest v21 for committing files
  • OAuth 2.0 Authorization Code + PKCE β€” authentication for Claude Teams connectors
  • Express.js β€” HTTP server wrapping the MCP transport
  • Next.js 15 β€” the blog platform (App Router, static pages per article)
  • AWS Amplify β€” CI/CD and hosting for the Next.js app
  • @tailwindcss/typography β€” prose styling for HTML article content
  • Docker β€” two-stage build for lean production images
  • TypeScript β€” throughout, with NodeNext module resolution in the MCP server

Want to build something similar or connect your own tools to Claude? β€” we run hands-on workshops covering exactly this kind of AI-native engineering.

How We Built an AI Blog Publisher Using MCP, AWS App Runner and GitHub API | InAgentic