<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: c-premus</title>
    <description>The latest articles on DEV Community by c-premus (@cpremus).</description>
    <link>https://dev.to/cpremus</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3845370%2F9b16ab0a-88ea-4796-87f1-743248fa6ccb.JPEG</url>
      <title>DEV Community: c-premus</title>
      <link>https://dev.to/cpremus</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cpremus"/>
    <language>en</language>
    <item>
      <title>Rewriting My MCP Server from PHP to Go</title>
      <dc:creator>c-premus</dc:creator>
      <pubDate>Wed, 15 Apr 2026 16:53:39 +0000</pubDate>
      <link>https://dev.to/cpremus/rewriting-my-mcp-server-from-php-to-go-5e1p</link>
      <guid>https://dev.to/cpremus/rewriting-my-mcp-server-from-php-to-go-5e1p</guid>
      <description>&lt;h2&gt;
  
  
  What this project is
&lt;/h2&gt;

&lt;p&gt;DocuMCP is a documentation server that exposes knowledge bases through the &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; (MCP). AI agents connect to it and can search, read, and manage documentation across multiple sources: uploaded documents (PDF, DOCX, XLSX, EPUB, HTML, Markdown), ZIM archives served by Kiwix, and Git template repositories. It also has a REST API and a Vue 3 admin panel.&lt;/p&gt;

&lt;p&gt;The original version was a Laravel application. It had 18 MCP tools, 7 prompts, an OAuth 2.1 authorization server, OIDC authentication, Meilisearch for full-text search, and 97%+ test coverage. It worked. This article is about why I rewrote it in Go and what the process taught me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why rewrite at all?
&lt;/h2&gt;

&lt;p&gt;The PHP version was not broken. Laravel is a productive framework, and the test coverage gave me confidence in the code. I rewrote it because of operational friction, not because I had a problem with PHP.&lt;/p&gt;

&lt;p&gt;The Laravel deployment required PHP, a process manager (RoadRunner or Octane), Meilisearch as a separate service, and the usual Composer dependency tree. The container image was around 200 MB. Each new deployment meant coordinating multiple processes, and each process was another thing that could fail at 3 AM.&lt;/p&gt;

&lt;p&gt;Go offered a different deployment model: a single statically-linked binary, a distroless container image (~45 MB), millisecond startup, and native concurrency without a process manager. All dependencies compile into the binary — no runtime interpreter, no extension loading, no shell needed in the container.&lt;/p&gt;

&lt;p&gt;The other factor was the MCP SDK. The &lt;a href="https://github.com/modelcontextprotocol/go-sdk" rel="noopener noreferrer"&gt;official Go MCP SDK&lt;/a&gt; had just reached a usable state, and I wanted the type safety and tooling that Go provides for protocol work. Registering a tool in the Go SDK looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;"unified_search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Search across ALL content types in a single request..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;InputSchema&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolInputSchema&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;       &lt;span class="s"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Properties&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handleUnifiedSearch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler is a typed function, the schema is validated at compile time, and the SDK manages the SSE transport. In PHP, I had more ceremony around JSON schema validation and request dispatching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture overview
&lt;/h2&gt;

&lt;p&gt;The Go version follows a layered structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cmd/documcp/           # Cobra CLI: serve, worker, migrate, version, health
internal/
  app/                 # Lifecycle: Foundation → ServerApp / WorkerApp
  auth/oauth/          # OAuth 2.1 authorization server
  auth/oidc/           # OIDC authentication (external IdP)
  handler/api/         # REST API handlers
  handler/mcp/         # MCP tool + prompt handlers
  handler/oauth/       # OAuth endpoint handlers
  model/               # Domain types (typed status constants, no ORM tags)
  repository/          # PostgreSQL queries (pgx, handwritten SQL)
  service/             # Business logic, error translation
  search/              # PostgreSQL FTS (tsvector/tsquery + pg_trgm)
  extractor/           # Document content extraction (PDF, DOCX, XLSX, EPUB, HTML, MD)
  client/kiwix/        # Kiwix ZIM archive client
  queue/               # River job queue (Postgres-native, admin UI at /admin/river)
  crypto/              # AES-256-GCM encryption at rest
  observability/       # OpenTelemetry, Prometheus, slog
web/frontend/          # Vue 3 + TypeScript (embedded in binary via go:embed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key lifecycle abstraction is &lt;code&gt;Foundation&lt;/code&gt;, a struct that owns all shared dependencies (database pool, Redis clients, repositories, search, extractors, encryption, observability). Both &lt;code&gt;ServerApp&lt;/code&gt; (HTTP) and &lt;code&gt;WorkerApp&lt;/code&gt; (job processing) receive a &lt;code&gt;Foundation&lt;/code&gt; and compose their own dependencies on top of it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Foundation&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Config&lt;/span&gt;      &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;
    &lt;span class="n"&gt;Logger&lt;/span&gt;      &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Logger&lt;/span&gt;
    &lt;span class="n"&gt;PgxPool&lt;/span&gt;     &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pgxpool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pool&lt;/span&gt;
    &lt;span class="n"&gt;RedisClient&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;
    &lt;span class="c"&gt;// ...repositories, search, extractors, encryption...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern makes the dependency graph explicit. Every service gets its dependencies through constructor injection, and Foundation provides the wiring point without a DI container.&lt;/p&gt;

&lt;p&gt;One nice bonus of using River for the job queue: it ships with an embeddable admin UI that I mount at &lt;code&gt;/admin/river&lt;/code&gt;. Queue visibility with zero extra infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dropping Meilisearch for PostgreSQL full-text search
&lt;/h2&gt;

&lt;p&gt;This was the biggest single change in terms of operational overhead. The PHP version used Meilisearch as a separate service for full-text search. Meilisearch is excellent software, but it was another process to run, another data store to keep in sync, and another thing to monitor.&lt;/p&gt;

&lt;p&gt;PostgreSQL already had all my data. It also has a mature full-text search engine that I think is underused. The Go version uses &lt;code&gt;tsvector&lt;/code&gt;/&lt;code&gt;tsquery&lt;/code&gt; with a custom text search configuration, &lt;code&gt;pg_trgm&lt;/code&gt; for fuzzy matching, and &lt;code&gt;unaccent&lt;/code&gt; for diacritic-insensitive search.&lt;/p&gt;

&lt;p&gt;The search layer is a &lt;code&gt;Searcher&lt;/code&gt; struct that accepts typed parameters and returns normalized results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Searcher&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;     &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pgxpool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pool&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Logger&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Searcher&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="n"&gt;SearchParams&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;SearchResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;expanded&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ExpandSynonyms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// Route to index-specific query builder&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IndexUID&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;IndexDocuments&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;searchDocuments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expanded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;IndexZimArchives&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;searchZimArchives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expanded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;IndexGitTemplates&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;searchGitTemplates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expanded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Federated search across all three indexes uses a &lt;code&gt;UNION ALL&lt;/code&gt; query, so it is one round trip to PostgreSQL instead of three separate HTTP calls to Meilisearch. Synonym expansion happens in Go before the query reaches PostgreSQL. The SQL stays simple and the synonyms are unit-testable.&lt;/p&gt;

&lt;p&gt;The tradeoff: PostgreSQL FTS requires more manual tuning than Meilisearch (custom dictionaries, index maintenance, query parsing). But it eliminated an external dependency, and the search quality is good enough for a documentation use case where queries are typically specific.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building OAuth 2.1 from scratch
&lt;/h2&gt;

&lt;p&gt;The PHP version also had a custom OAuth implementation. For Go, the common choice is &lt;a href="https://github.com/ory/fosite" rel="noopener noreferrer"&gt;fosite&lt;/a&gt;, but I decided to build the OAuth 2.1 authorization server from scratch again.&lt;/p&gt;

&lt;p&gt;The reason was scope. DocuMCP needs a specific set of OAuth flows: authorization code with PKCE (required by OAuth 2.1), device authorization (RFC 8628) for CLI/agent clients that lack a browser, and dynamic client registration (RFC 7591) so MCP clients can register themselves. It also publishes RFC 9728 Protected Resource Metadata so clients can discover the authorization server automatically.&lt;/p&gt;

&lt;p&gt;Building this on fosite would have meant learning fosite's abstraction layer, mapping my storage to its interfaces, and debugging through its middleware chain when something didn't match. For a well-scoped set of RFCs, the direct implementation was more predictable.&lt;/p&gt;

&lt;p&gt;The OAuth service follows the same patterns as the rest of the codebase. It defines a repository interface where it is consumed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;OAuthRepo&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;CreateClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OAuthClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
    &lt;span class="n"&gt;FindClientByClientID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clientID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OAuthClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;CreateAuthorizationCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OAuthAuthorizationCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
    &lt;span class="n"&gt;FindAuthorizationCodeByCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;codeHash&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OAuthAuthorizationCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;RevokeAuthorizationCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
    &lt;span class="c"&gt;// ...access tokens, refresh tokens, device codes&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Service&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;repo&lt;/span&gt;   &lt;span class="n"&gt;OAuthRepo&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OAuthConfig&lt;/span&gt;
    &lt;span class="n"&gt;appURL&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Logger&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All tokens are stored as HMAC-SHA256 hashes. The HMAC key is derived from the session secret using HKDF with a salt, so the raw token values never touch the database. The OAuth consent pages use &lt;code&gt;html/template&lt;/code&gt;. No JavaScript framework, no XSS surface.&lt;/p&gt;

&lt;p&gt;I will cover the OAuth implementation in more detail in Part 2 of this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ZIM fan-out problem
&lt;/h2&gt;

&lt;p&gt;The most interesting engineering challenge was federated search across ZIM archives. DocuMCP integrates with &lt;a href="https://kiwix.org/" rel="noopener noreferrer"&gt;Kiwix Serve&lt;/a&gt;, which hosts ZIM files — offline snapshots of sites like DevDocs, Wikipedia, and Stack Exchange. One instance can serve hundreds of archives.&lt;/p&gt;

&lt;p&gt;The unified search tool needs to search across all relevant archives in a single MCP tool call. The naive approach (fan out to every searchable archive in parallel) collapsed when the archive count reached ~470 (mostly DevDocs). Kiwix Serve could not handle hundreds of concurrent search requests, and everything timed out.&lt;/p&gt;

&lt;p&gt;The fix had three parts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. FTS pre-filtering.&lt;/strong&gt; Before fanning out, the server searches its own &lt;code&gt;zim_archives&lt;/code&gt; table using PostgreSQL FTS to find archives relevant to the query. If you search for "react hooks," you get React-related archives, not all 470.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;searcher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SearchParams&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IndexUID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IndexZimArchives&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;federatedMaxArchives&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Budget splitting.&lt;/strong&gt; DevDocs archives are small (fewer than 1000 articles each) and highly relevant for programming queries. They get their own fan-out budget separate from larger general archives like Wikipedia. This prevents a few large archives from crowding out dozens of small, relevant DevDocs archives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Semaphore-bounded concurrency.&lt;/strong&gt; The actual fan-out uses a channel-based semaphore capped at 10 concurrent requests, with a configurable deadline on the entire batch (default 3 seconds, &lt;code&gt;KIWIX_FEDERATED_SEARCH_TIMEOUT&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;sem&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;archives&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;wg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;wg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;sem&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{}{}&lt;/span&gt;        &lt;span class="c"&gt;// acquire&lt;/span&gt;
        &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;sem&lt;/span&gt; &lt;span class="p"&gt;}()&lt;/span&gt; &lt;span class="c"&gt;// release&lt;/span&gt;

        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;kiwixClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fanoutCtx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;searchType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}(&lt;/span&gt;&lt;span class="n"&gt;archives&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This brought search times from 30-second timeouts down to about 250ms on a warm cache, across the ~20 archives that actually match a typical query after FTS selection.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few surprises
&lt;/h2&gt;

&lt;p&gt;Error translation turned out to be worth the boilerplate. The service layer translates repository errors into domain errors, and handlers translate domain errors into HTTP status codes. It feels like extra work up front, but it means the MCP handler and the REST handler share the same service code without leaking database details:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Service layer&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ErrNotFound&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"not found"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;DocumentService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FindByUUID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FindByUUID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrNoRows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"document %s: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrNotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Two Redis clients, not one
&lt;/h3&gt;

&lt;p&gt;I ended up with two Redis clients: one instrumented with OpenTelemetry for the event bus and application-level operations, and one bare client for rate limiting and health checks. The rate limiter uses &lt;code&gt;MULTI/INCR/EXPIRE/EXEC&lt;/code&gt; pipelines at high frequency, and tracing every one of those creates noise that drowns out meaningful spans. The bare client has &lt;code&gt;MaxRetries: -1&lt;/code&gt; to prevent retry-induced partial responses in the pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pure Go extraction actually worked
&lt;/h3&gt;

&lt;p&gt;I expected to need cgo or external tools for PDF and DOCX extraction. Instead, the extractor pipeline uses pure Go libraries for all six formats: PDF, DOCX, XLSX, EPUB, HTML, and Markdown. The EPUB case was what convinced me — an EPUB file is a ZIP of XHTML files, so the whole thing is &lt;code&gt;archive/zip&lt;/code&gt; + &lt;code&gt;encoding/xml&lt;/code&gt; + &lt;code&gt;bluemonday&lt;/code&gt; for sanitization + &lt;code&gt;htmltomarkdown&lt;/code&gt; for chapter processing. No external dependencies, no cgo. Each extractor has configurable limits (max extracted text size, max ZIP files for DOCX, max sheets for XLSX).&lt;/p&gt;

&lt;p&gt;OIDC discovery was the other thing that bit me. The OIDC client fetches the provider's &lt;code&gt;.well-known/openid-configuration&lt;/code&gt; at startup. Early in development, transient provider unavailability during startup caused permanent failures and the server would crash and restart in a loop. Exponential backoff with retries fixed it. A small thing, but a real problem in any environment where the identity provider starts up alongside the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  If I did it again
&lt;/h2&gt;

&lt;p&gt;I would hit the MCP SDK's SSE transport with focused integration tests from day one. The Go MCP SDK was new, and its SSE transport had behaviors I didn't expect. I spent time debugging connection lifecycle issues that would have shown up in tests.&lt;/p&gt;

&lt;p&gt;I would also use sqlc or a query builder for the simpler queries. I wrote all SQL by hand with pgx. For the complex FTS queries and &lt;code&gt;UNION ALL&lt;/code&gt; federated search, that was the right call — no query builder would have generated those. But for basic CRUD, handwritten SQL is repetitive and error-prone when columns change. A hybrid approach (sqlc for CRUD, handwritten for complex queries) would have saved time.&lt;/p&gt;

&lt;p&gt;Third, I'd nail down structured logging conventions earlier. slog is good, but I spent time later standardizing which fields to include across handlers. Picking a convention (request ID, user ID, operation name) up front would have avoided the cleanup pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;PHP (Laravel)&lt;/th&gt;
&lt;th&gt;Go&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Container image&lt;/td&gt;
&lt;td&gt;~200 MB&lt;/td&gt;
&lt;td&gt;~45 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP tools&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP prompts&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External search dependency&lt;/td&gt;
&lt;td&gt;Meilisearch&lt;/td&gt;
&lt;td&gt;None (PostgreSQL FTS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test coverage&lt;/td&gt;
&lt;td&gt;97%+&lt;/td&gt;
&lt;td&gt;76.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linter rules&lt;/td&gt;
&lt;td&gt;PHPStan L9&lt;/td&gt;
&lt;td&gt;golangci-lint, 26 linters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime processes&lt;/td&gt;
&lt;td&gt;PHP + process manager + Meilisearch&lt;/td&gt;
&lt;td&gt;Single binary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment artifact&lt;/td&gt;
&lt;td&gt;Container + config&lt;/td&gt;
&lt;td&gt;Single binary or container&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The tool count went down because I dropped the Confluence integration (the API complexity was not worth maintaining for a feature nobody used) and consolidated some overlapping tools. Test coverage is lower in Go (76.7% vs 97%), partly because the PHP codebase was older and more fully tested, and partly because I prioritized integration-level tests over exhaustive unit coverage for repository code that is mostly SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Up next
&lt;/h2&gt;

&lt;p&gt;Part 2 covers two subsystems in detail: the OAuth 2.1 authorization server (PKCE, device authorization, dynamic client registration) and the MCP protocol integration (tool registration, SSE transport, authentication flow).&lt;/p&gt;




&lt;p&gt;The code is open source at &lt;a href="https://github.com/c-premus/documcp" rel="noopener noreferrer"&gt;github.com/c-premus/documcp&lt;/a&gt;. If you have questions about the architecture or want to discuss any of the decisions, I am happy to talk about it in the comments.&lt;/p&gt;

</description>
      <category>go</category>
      <category>mcp</category>
      <category>architecture</category>
      <category>postgres</category>
    </item>
    <item>
      <title>I Couldn't Find an OAuth 2.1 Proxy for MCP Servers, So I Built One</title>
      <dc:creator>c-premus</dc:creator>
      <pubDate>Fri, 27 Mar 2026 01:52:12 +0000</pubDate>
      <link>https://dev.to/cpremus/i-couldnt-find-an-oauth-21-proxy-for-mcp-servers-so-i-built-one-59nd</link>
      <guid>https://dev.to/cpremus/i-couldnt-find-an-oauth-21-proxy-for-mcp-servers-so-i-built-one-59nd</guid>
      <description>&lt;p&gt;When I started deploying custom MCP servers to connect to Claude.ai, I hit a wall fast.&lt;/p&gt;

&lt;p&gt;Claude.ai's custom connector flow requires your MCP server to implement &lt;a href="https://datatracker.ietf.org/doc/html/rfc9728" rel="noopener noreferrer"&gt;OAuth 2.1 Protected Resource Metadata&lt;/a&gt; — specifically RFC 9728 — before it will even attempt to authenticate. No RFC 9728 &lt;code&gt;/.well-known/oauth-protected-resource&lt;/code&gt; endpoint? Silent failure. No error. The connector just doesn't work.&lt;/p&gt;

&lt;p&gt;I went looking for an existing solution. Something that could sit in front of any MCP server, handle the spec compliance, validate JWTs, and get out of the way. Nothing existed. So I built it: &lt;strong&gt;&lt;a href="https://github.com/c-premus/mcp-gate" rel="noopener noreferrer"&gt;mcp-gate&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Problem Actually Is
&lt;/h2&gt;

&lt;p&gt;When Claude.ai connects to a custom MCP server, the flow looks roughly like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Claude.ai fetches &lt;code&gt;/.well-known/oauth-protected-resource&lt;/code&gt; from your server&lt;/li&gt;
&lt;li&gt;That endpoint must return RFC 9728 metadata pointing to your authorization server&lt;/li&gt;
&lt;li&gt;Claude.ai negotiates an OAuth 2.1 token with that authorization server&lt;/li&gt;
&lt;li&gt;Subsequent requests carry a Bearer JWT, which your server must validate via JWKS&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most MCP server implementations — whether you're writing one in Go, Python, TypeScript, or using an off-the-shelf server — don't implement any of this. They're focused on the MCP protocol itself, not on auth infrastructure. Bolting RFC 9728 + JWKS validation directly into every server you deploy is the wrong abstraction anyway.&lt;/p&gt;

&lt;p&gt;What you actually want is a &lt;strong&gt;sidecar proxy&lt;/strong&gt; that handles auth uniformly, regardless of what's running behind it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What mcp-gate Does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;mcp-gate&lt;/code&gt; is a stateless Go binary that sits between Claude.ai and your MCP server:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fin4l4rrbxquqo7m0rlzo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fin4l4rrbxquqo7m0rlzo.png" alt="mcp-gate architecture" width="800" height="207"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Serves &lt;code&gt;/.well-known/oauth-protected-resource&lt;/code&gt;&lt;/strong&gt; — RFC 9728 compliant metadata, pointing Claude.ai to your authorization server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validates Bearer JWTs on every request&lt;/strong&gt; — fetches and caches JWKS from your auth server, validates signature (RS256), expiry, issuer, audience, and scope claims&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reverse proxies to your upstream MCP server&lt;/strong&gt; — if the token is valid, the request goes through; otherwise 401&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. It has no opinions about what your MCP server does. It works with any OIDC-compliant authorization server: Authentik, Keycloak, Okta, Auth0, or anything else that issues JWTs with a JWKS endpoint.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LISTEN_ADDR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.0.0.0:8080
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;UPSTREAM_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://your-mcp-server:8000
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RESOURCE_URI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://mcp.yourdomain.com
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AUTHORIZATION_SERVER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://auth.yourdomain.com/application/o/mcp/
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;JWKS_URI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://auth.yourdomain.com/application/o/mcp/jwks/
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;EXPECTED_ISSUER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://auth.yourdomain.com/application/o/mcp/
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;EXPECTED_AUDIENCE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-client-id

go run ./cmd/mcp-gate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or with Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull cpremus/mcp-gate:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;docker-compose.example.yml&lt;/code&gt; is included in the repo showing how to wire it up alongside a typical MCP server container.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Note on Logging
&lt;/h2&gt;

&lt;p&gt;Every request is logged as structured JSON — &lt;code&gt;method&lt;/code&gt;, &lt;code&gt;path&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;duration_ms&lt;/code&gt;, &lt;code&gt;client_ip&lt;/code&gt;, &lt;code&gt;user_agent&lt;/code&gt; — designed for ingestion by Loki/Alloy or any structured log aggregator. If you're running a self-hosted observability stack, it drops right in.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why a Separate Binary
&lt;/h2&gt;

&lt;p&gt;A few design decisions worth explaining:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stateless by design.&lt;/strong&gt; mcp-gate holds no session state. JWKS keys are cached in-memory with TTL-based refresh. This makes horizontal scaling trivial and keeps the failure surface small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No config files.&lt;/strong&gt; Everything is environment variables. Works cleanly in Docker, Kubernetes, or bare metal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deliberately narrow scope.&lt;/strong&gt; mcp-gate does auth and proxying. It doesn't do request transformation or protocol-level concerns. Those belong elsewhere in your stack.&lt;/p&gt;




&lt;h2&gt;
  
  
  Status
&lt;/h2&gt;

&lt;p&gt;The core is stable and running in production on my own infrastructure. Current release handles everything needed for Claude.ai custom connector integration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Observability update: end-to-end trace propagation
&lt;/h2&gt;

&lt;p&gt;Since publishing, I've added W3C trace context propagation through the reverse proxy. &lt;code&gt;traceparent&lt;/code&gt; and &lt;code&gt;tracestate&lt;/code&gt; headers now carry through to the upstream MCP server, so you get a single distributed trace spanning the full request path — not just mcp-gate's side of it. If your upstream supports OTel (or you add it later), the traces connect automatically&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/c-premus/mcp-gate" rel="noopener noreferrer"&gt;github.com/c-premus/mcp-gate&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Hub:&lt;/strong&gt; &lt;a href="https://hub.docker.com/r/cpremus/mcp-gate" rel="noopener noreferrer"&gt;hub.docker.com/r/cpremus/mcp-gate&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setup Guide:&lt;/strong&gt; &lt;a href="https://github.com/c-premus/mcp-gate/blob/main/docs/setup.md" rel="noopener noreferrer"&gt;docs/setup.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building or deploying MCP servers and haven't hit this yet, you will. Hopefully this saves you the afternoon I lost to it.&lt;/p&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
