Skip to content

feat(stack): add stdio MCP bridge#160

Merged
flemzord merged 5 commits into
mainfrom
feat/stack-mcp-stdio-bridge
May 28, 2026
Merged

feat(stack): add stdio MCP bridge#160
flemzord merged 5 commits into
mainfrom
feat/stack-mcp-stdio-bridge

Conversation

@flemzord

@flemzord flemzord commented May 26, 2026

Copy link
Copy Markdown
Member

Summary

  • add fctl stack mcp serve --transport stdio
  • bridge local stdio MCP requests to the selected stack /api/mcp endpoint
  • reuse the existing stack token source/auth flow for MCP instead of adding custom scope/resource token plumbing
  • add tests for MCP framing, remote forwarding, session headers, context cancellation, and SSE responses

Validation

  • go test ./cmd/stack/mcp ./cmd/stack ./pkg
  • go test ./...

Notes

  • The bridge uses the current stack token behavior. Requested scope/resource plumbing is intentionally not part of this PR because the current formancehq/auth jwt-bearer grant flow does not apply requested scopes beyond membership/assertion scopes and does not consume RFC 8707 resource yet.
  • If auth gains real support for resource-bound or custom-scoped jwt-bearer tokens, fctl can add that request plumbing in a coordinated follow-up.

@flemzord flemzord requested a review from a team as a code owner May 26, 2026 15:08
@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds a new stack mcp serve CLI command that bridges Model Context Protocol (MCP) JSON-RPC messages over stdio to a remote MCP HTTP API. Includes refactored OAuth2 token acquisition supporting optional API resource scoping, comprehensive framing/routing/session/response-decoding logic, and full unit test coverage.

Changes

MCP Stack Command with API-Scoped Token Authentication

Layer / File(s) Summary
Stack root command registration
cmd/stack/root.go
Imports MCP command package and registers mcp.NewCommand() under the stack root.
API-scoped token authentication helper
pkg/authentication.go
Introduces FetchStackTokenWithScopesAndResource to construct OAuth JWT-bearer payloads with optional resource parameter. Existing functions delegate to this helper.
stack mcp serve command and entry point
cmd/stack/mcp/root.go (lines 1–88)
Defines mcp and serve Cobra commands with stdio transport validation. Entry point authenticates profile, resolves stack, mints API-scoped OAuth2 token, creates HTTP client, and starts stdio bridge.
Stdio JSON-RPC server loop and framing
cmd/stack/mcp/root.go (lines 90–414)
Implements Serve loop reading framed messages from stdin (Content-Length with blank-line terminator or newline fallback), unmarshals JSON-RPC, routes requests/notifications separately, writes JSON-RPC responses to stdout, stops on EOF. Includes readMCPMessage for framing and writeJSONRPCResponse for output.
Notification and request routing
cmd/stack/mcp/root.go (lines 196–219)
Forwards notifications/cancelled and notifications/initialized to remote client. Routes requests by special-casing ping (returns local empty object) or forwarding to remote; remote failures map to JSON-RPC error code -32000.
Remote MCP HTTP client and response decoding
cmd/stack/mcp/root.go (lines 221–364)
Constructs /api/mcp POSTs with MCP headers, manages optional Mcp-Session-Id (sent on requests, updated from responses), captures protocol version from initialize, treats 202 Accepted as empty for notifications, validates HTTP status, and decodes JSON or SSE (text/event-stream) responses with exactly-one-event requirement.
Comprehensive unit tests
cmd/stack/mcp/root_test.go
Covers framing (Content-Length, oversized rejection), server behavior (forwarding, cancellation, error preservation), session reuse/protocol propagation, and SSE decoding (single vs. multiple events).

Sequence Diagram

sequenceDiagram
  participant Client as Client<br/>(stdio)
  participant StdioServer as Stdio Server
  participant RemoteClient as Remote Client<br/>(HTTP)
  participant RemoteAPI as Remote MCP<br/>Endpoint

  Client->>StdioServer: JSON-RPC request/notification<br/>(framed)
  StdioServer->>StdioServer: Parse frame,<br/>unmarshal JSON-RPC
  
  alt notification
    StdioServer->>RemoteClient: Forward notification
  else request (ping)
    StdioServer->>StdioServer: Handle locally
  else request (other)
    StdioServer->>RemoteClient: Forward request
  end
  
  RemoteClient->>RemoteAPI: POST /api/mcp<br/>with session/protocol headers
  RemoteAPI->>RemoteClient: JSON or SSE response
  RemoteClient->>RemoteClient: Decode response<br/>(JSON-RPC or SSE)
  RemoteClient->>StdioServer: Return result/error
  
  StdioServer->>StdioServer: Compose JSON-RPC<br/>response
  StdioServer->>Client: JSON-RPC response<br/>(newline-terminated)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A bridge is built with stdio care,
MCP requests float through the air,
Content-Length frames guide the way,
JSON-RPC at the protocol's play,
Remote endpoints now in reach!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(stack): add stdio MCP bridge' is clear, concise, and accurately summarizes the main feature being added—a stdio-based MCP bridge for the stack command.
Description check ✅ Passed The description provides clear context about the MCP bridge implementation, validation approach, and implementation notes about token/auth reuse, directly relating to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/stack-mcp-stdio-bridge

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/stack/mcp/root.go`:
- Around line 185-193: The handler for "notifications/cancelled" currently only
logs locally; modify stdioServer.handleNotification so that when msg.Method ==
"notifications/cancelled" it forwards the same msg to the remote MCP server by
calling s.remote.Notify(context.Background(), msg) (just like the
"notifications/initialized" branch), check and log any error from that call
(e.g., using s.err or similar) while still preserving the local cancellation
log; use the existing rpcMessage and s.remote.Notify symbols to locate and
implement the change.
- Around line 364-380: The code currently parses Content-Length via
strconv.Atoi(headerOrJSON) and then allocates payload := make([]byte, length)
without checking for negative values; strconv.Atoi("-1") succeeds and causes a
panic on make. Update the parsing branch (identify headerOrJSON, strconv.Atoi
call, and payload := make([]byte, length)) to validate length >= 0 after
conversion and return a framing/error (e.g., fmt.Errorf("invalid Content-Length:
%d", length)) if length is negative (or otherwise out of expected bounds) before
attempting to allocate or read the payload.
- Around line 196-211: The default case in stdioServer.handleRequest currently
forwards the request to s.remote.Request but masks any upstream failures as
rpcError Code -32601 ("method not found"); change it to propagate the real error
instead: when s.remote.Request(ctx, msg) returns a non-nil err, return
&rpcError{Code: -32000, Message: err.Error()} (or otherwise include err.Error())
so auth/transport/5xx failures are preserved; only return -32601 when there is a
clear indication that the remote responded with an actual "method not found"
condition. Reference: stdioServer.handleRequest, s.remote.Request, rpcError.

In `@pkg/clients.go`:
- Around line 644-645: The cache logic incorrectly ignores apiResource, so
callers requesting a resource-bound token can read/write an unscoped cache
entry; update the cache guard where ReadCachedStackAPIToken (and symmetric
WriteCachedStackAPIToken) are used to require both len(t.apiScopes) == 0 AND
t.apiResource == "" (i.e., only use the shared unscoped cache when there are no
apiScopes AND no apiResource), and apply the same change to the other occurrence
around the Read/Write calls referenced (the block near the second occurrence at
lines 679-680).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ee036afa-b578-41e5-b8a8-21a2d8986cfa

📥 Commits

Reviewing files that changed from the base of the PR and between 28d67e4 and 663e33d.

📒 Files selected for processing (5)
  • cmd/stack/mcp/root.go
  • cmd/stack/mcp/root_test.go
  • cmd/stack/root.go
  • pkg/authentication.go
  • pkg/clients.go

Comment thread cmd/stack/mcp/root.go
Comment thread cmd/stack/mcp/root.go Outdated
Comment thread cmd/stack/mcp/root.go
Comment thread pkg/clients.go Outdated
@flemzord flemzord enabled auto-merge (squash) May 26, 2026 21:48
Comment thread pkg/authentication.go Outdated

@gfyrag gfyrag left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review / Revue


🇬🇧 English

Overall: Solid PR — clean architecture, good test coverage, backward-compatible auth changes. A few items to address before merge.

Auth compatibility with formancehq/auth

I checked the auth server code. There are two important findings:

  1. The scope request parameter is ignored in the jwt-bearer grant flow. In pkg/oidc/grant_type_bearer.go, ValidateJWTProfileScopes() returns scopes as-is from the JWT assertion — the scope form parameter sent by fctl is not used. The e2e test (test/e2e/jwt_bearer_test.go) confirms this: requested scopes are ignored, only assertion scopes end up in the token. So ledger:read, payments:read, reconciliation:read won't actually appear in the resulting token unless the auth server is updated.

  2. The resource parameter (RFC 8707) is not implemented. The auth server has no code handling it. Audience is always set to ApplicationID (client ID), not to the resource URL. The resource form value sent by fctl is silently dropped.

This is consistent with the PR description ("auth-side change needed"), but worth calling out explicitly: both scope and resource plumbing are dead code until the auth server supports them.

Code issues

  • readMCPMessage has no Content-Length upper bound (root.go:~330): A Content-Length: 9999999999 would allocate ~10GB. Add a max size check (e.g. 10MB) before make([]byte, length).

  • handleRequest switch is redundant (root.go:~175-185): The default case does exactly the same thing as the "initialize", "tools/list", "tools/call" case. Either simplify to just forward everything, or add a // TODO comment explaining future differentiation.

  • decodeSSEResponse only handles a single SSE event (root.go:~290): All data: lines are joined and decoded as one response. If the remote sends multiple SSE events (streaming MCP), this will break. Fine for now if the remote always sends a single event, but should be documented.

  • No ctx.Done() check in Serve loop (root.go:~137): The loop blocks on readMCPMessage(reader) without listening to context cancellation. Graceful shutdown won't work until stdin closes.

Token source API

The 3-constructor chain (NewStackTokenSourceWithAPIScopesWithAPIScopesAndResource) works but is getting heavy. Consider a functional options or config struct pattern if more parameters are added.

Cache bypass for custom scopes/resources is correct — good call not polluting the shared cache.


🇫🇷 Français

Globalement : PR solide — architecture propre, bonne couverture de tests, changements auth backward-compatible. Quelques points à traiter avant le merge.

Compatibilité auth avec formancehq/auth

J'ai vérifié le code du serveur auth. Deux constats importants :

  1. Le paramètre scope de la requête est ignoré dans le flow jwt-bearer. Dans pkg/oidc/grant_type_bearer.go, ValidateJWTProfileScopes() retourne les scopes tels quels depuis le JWT d'assertion — le paramètre scope du formulaire envoyé par fctl n'est pas utilisé. Le test e2e (test/e2e/jwt_bearer_test.go) le confirme : les scopes demandés sont ignorés, seuls ceux de l'assertion se retrouvent dans le token. Donc ledger:read, payments:read, reconciliation:read ne seront pas dans le token résultant sans mise à jour du serveur auth.

  2. Le paramètre resource (RFC 8707) n'est pas implémenté. Le serveur auth n'a aucun code pour le gérer. L'audience est toujours le ApplicationID (client ID), pas l'URL de la ressource. La valeur resource envoyée par fctl est silencieusement ignorée.

C'est cohérent avec la description de la PR ("auth-side change needed"), mais ça mérite d'être explicité : le plumbing scope et resource est du code mort tant que le serveur auth ne les supporte pas.

Problèmes dans le code

  • readMCPMessage n'a pas de limite haute sur Content-Length (root.go:~330) : Un Content-Length: 9999999999 allouerait ~10GB. Ajouter une vérification de taille max (ex: 10MB) avant make([]byte, length).

  • Le switch dans handleRequest est redondant (root.go:~175-185) : Le default fait exactement la même chose que le case "initialize", "tools/list", "tools/call". Soit simplifier en forwardant tout, soit ajouter un commentaire // TODO expliquant la différenciation future.

  • decodeSSEResponse ne gère qu'un seul événement SSE (root.go:~290) : Toutes les lignes data: sont jointes et décodées comme une seule réponse. Si le remote envoie plusieurs événements SSE (streaming MCP), ça cassera. OK pour l'instant si le remote envoie toujours un seul événement, mais à documenter.

  • Pas de vérification ctx.Done() dans la boucle Serve (root.go:~137) : La boucle bloque sur readMCPMessage(reader) sans écouter l'annulation du contexte. Le shutdown gracieux ne fonctionnera pas tant que stdin n'est pas fermé.

API TokenSource

La chaîne de 3 constructeurs (NewStackTokenSourceWithAPIScopesWithAPIScopesAndResource) fonctionne mais devient lourde. Envisager un pattern functional options ou config struct si d'autres paramètres s'ajoutent.

Le bypass du cache pour les scopes/resources custom est correct — bonne décision de ne pas polluer le cache partagé.

@flemzord

Copy link
Copy Markdown
Member Author

Addressed the new review points in a5f38f1:

  • added a 10 MiB upper bound for stdio Content-Length before allocation
  • simplified handleRequest so only local ping is special-cased and all other methods are forwarded consistently
  • made Serve return on context cancellation instead of waiting indefinitely for stdin
  • made SSE decoding explicit about the current single-response limitation by rejecting multi-event SSE payloads
  • updated the PR notes to call out that current auth ignores requested jwt-bearer scopes and does not consume resource yet

Validation: go test ./cmd/stack/mcp and go test ./....

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Actionable comments posted: 0

@flemzord

Copy link
Copy Markdown
Member Author

Simplified in 6f9f4fb after the scope/resource discussion:

  • removed the custom jwt-bearer scope/resource plumbing from fctl
  • removed the MCP-specific scope list and the WithAPIScopes* stack token source constructors
  • MCP now reuses the standard stack token source/auth behavior
  • updated the PR notes to make explicit that real resource-bound/custom-scoped jwt-bearer support belongs in a coordinated auth-side follow-up

Validation: go test ./cmd/stack/mcp ./cmd/stack ./pkg and go test ./....

gfyrag
gfyrag previously approved these changes May 28, 2026

@gfyrag gfyrag left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dirty

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cmd/stack/mcp/root.go (1)

66-78: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use relying-party HTTP transport for the MCP OAuth2 client

  • runServe creates httpClient := oauth2.NewClient(cmd.Context(), tokenSource) but cmd.Context() isn’t augmented with oauth2.HTTPClient from relyingParty.HttpClient() anywhere in the MCP stack path.
  • All MCP /api/mcp requests use this returned client (remoteMCPClient calls c.httpClient.Do(req)), so custom TLS/proxy/transport settings from relyingParty.HttpClient() can be skipped even though token refresh already uses relyingParty.HttpClient() inside NewStackTokenSource.
Suggested fix
 	tokenSource := fctl.NewStackTokenSource(
 		*stackToken,
 		stackAccess,
 		relyingParty,
 		func(newToken fctl.AccessToken) error {
 			return fctl.WriteStackToken(cmd, profileName, stackID, newToken)
 		},
 		cmd,
 		profileName,
 		organizationID,
 		stackID,
 	)
-	httpClient := oauth2.NewClient(cmd.Context(), tokenSource)
+	oauthCtx := context.WithValue(cmd.Context(), oauth2.HTTPClient, relyingParty.HttpClient())
+	httpClient := oauth2.NewClient(oauthCtx, tokenSource)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/stack/mcp/root.go` around lines 66 - 78, runServe currently calls
oauth2.NewClient(cmd.Context(), tokenSource) which ignores the custom transport
from relyingParty.HttpClient(); instead, create a context that injects the
relyingParty HTTP client via oauth2.HTTPClient (e.g. ctx :=
context.WithValue(cmd.Context(), oauth2.HTTPClient, relyingParty.HttpClient()))
and pass that context to oauth2.NewClient so the returned httpClient used by
remoteMCPClient uses the relyingParty transport; update the creation of
httpClient (and any uses of tokenSource if needed) to use this new ctx while
keeping NewStackTokenSource and token refresh behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@cmd/stack/mcp/root.go`:
- Around line 66-78: runServe currently calls oauth2.NewClient(cmd.Context(),
tokenSource) which ignores the custom transport from relyingParty.HttpClient();
instead, create a context that injects the relyingParty HTTP client via
oauth2.HTTPClient (e.g. ctx := context.WithValue(cmd.Context(),
oauth2.HTTPClient, relyingParty.HttpClient())) and pass that context to
oauth2.NewClient so the returned httpClient used by remoteMCPClient uses the
relyingParty transport; update the creation of httpClient (and any uses of
tokenSource if needed) to use this new ctx while keeping NewStackTokenSource and
token refresh behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c5ddac6b-9f64-4c74-b40e-3fbdfbbdd626

📥 Commits

Reviewing files that changed from the base of the PR and between a5f38f1 and ca7e505.

📒 Files selected for processing (3)
  • cmd/stack/mcp/root.go
  • cmd/stack/mcp/root_test.go
  • pkg/authentication.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • pkg/authentication.go
  • cmd/stack/mcp/root_test.go

@flemzord flemzord merged commit 0d297ab into main May 28, 2026
5 checks passed
@flemzord flemzord deleted the feat/stack-mcp-stdio-bridge branch May 28, 2026 17:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants