Note:
CLAUDE.mdis a symlink to this file. OnlyAGENTS.mdneeds to be edited; changes are automatically reflected inCLAUDE.md.
This file provides essential information for AI agents working on the LFX MCP Server codebase. It focuses on development workflows, architecture understanding, and build processes needed for making code changes.
The LFX MCP Server is a Model Context Protocol (MCP) implementation that provides tools and resources for interacting with the Linux Foundation's LFX platform. It's built using the official Go SDK for MCP and follows a clean, extensible architecture.
- Language: Go (see
go.modfor the minimum required version) - Protocol: Model Context Protocol (MCP) 2024-11-05
- SDK: Official MCP Go SDK (see
go.modfor version) - Transport: JSON-RPC 2.0 over stdio and Streamable HTTP
- Schema: Automatic JSON schema generation via struct tags
The service follows a simple, clean architecture pattern optimized for MCP tool development:
lfx-mcp/
├── cmd/
│ └── lfx-mcp-server/ # Main application entry point
├── internal/
│ ├── auth/ # JWT and API-key verification
│ ├── lfxv2/ # LFX V2 API client
│ ├── otel/ # OpenTelemetry instrumentation
│ ├── serviceapi/ # Shared service API helpers
│ └── tools/ # MCP tool implementations
├── charts/ # Helm chart for Kubernetes deployment
├── scripts/ # Test and utility scripts
├── bin/ # Built binaries (gitignored)
├── go.mod # Go module definition
├── Makefile # Build automation
├── README.md # User documentation
└── AGENTS.md # This file (AI agent guidelines)
Client (Claude, etc.) → JSON-RPC 2.0 → stdio transport → MCP Server → Tool Handler → Response
- Simplicity: Minimal abstraction layers using the official MCP Go SDK
- Extensibility: Easy to add new tools through
mcp.AddToolandnewServerscope gating - Type Safety: Strong typing with automatic schema generation
- Testability: Simple stdio testing via JSON-RPC messages
- Observability: Structured JSON logging with optional debug mode
The HTTP server is designed to run across multiple pods without coordination:
Stateless: trueis set onStreamableHTTPHandler(main.go). This instructs the SDK to skip session-ID validation and use a temporary session per request, so any pod can handle any request.- Per-request server factory:
newServer()is called for each incoming HTTP request, so no MCP-level state accumulates across requests. SchemaCache: A package-levelschemaCacheis shared across per-request server instances. This avoids re-running reflection-based JSON schema generation for every request — schemas are computed on first use and then reused across subsequent requests in the same pod.- Streamable HTTP vs. old SSE transport: Responses may use SSE framing (
text/event-stream) within a single request/response cycle. This is not the deprecated long-lived SSE transport, so no connection affinity is needed between requests. - No sticky sessions required: Because all state is either per-request or independently cached per pod, round-robin load balancing works without Kubernetes session affinity.
- In-memory caches are all safe: Token exchange, slug resolver, client credentials, and JWKS caches are performance-only; each pod warms independently and misses trigger upstream re-fetches.
Stateless mode limitation: The server cannot make client callbacks (e.g., ListRoots, CreateMessage, Elicit) in stateless mode. We do not use any of these currently. If sampling or elicitation features are ever needed, stateless mode would need to be reconsidered.
Reference: SDK distributed example — uses the same Stateless: true + per-request factory + round-robin pattern.
# Verify Go is installed at or above the version in go.mod.
go versionNote: Stdio mode has no per-request OAuth context, so LFX data tools typically fail auth there. Use HTTP mode with OAuth configured, or enable only
hello_worldvia-tools/LFXMCP_TOOLSfor smoke tests. There is currently no personal access token (PAT) capability in LFX, so running the full server locally for end-to-end use is not practical without a complete OAuth setup.
make build
# or directly: go build -ldflags="-s -w" -o bin/lfx-mcp-server ./cmd/lfx-mcp-server# Integration tests
./scripts/test_server.sh
# Integration tests with debug logging
./scripts/test_server.sh --debug
# Manual testing
make run # Starts server in stdio mode
# Manual testing with debug logging
./bin/lfx-mcp-server -debugmake fmt # Format code
make vet # Run go vet
make lint # Run golangci-lint (if installed)
make check # Run all checksmake cleanThe server has two separate logging systems:
Uses Go's standard slog package for operational logs. These logs are written to stdout (HTTP mode) or stderr (stdio mode).
Configuration:
- Format: JSON (always)
- Output: stdout (HTTP mode) or stderr (stdio mode)
- Default Level: INFO
- Debug Mode: Enabled via
-debugflag orLFXMCP_DEBUG=trueenvironment variable
Enable debug logging:
# Via command-line flag
./bin/lfx-mcp-server -debug
# Via environment variable
LFXMCP_DEBUG=true ./bin/lfx-mcp-server
# Both work in HTTP mode too
./bin/lfx-mcp-server -mode=http -debugTools can send logs to the MCP client using mcp.NewLoggingHandler. These logs appear in the client's UI (e.g., Claude Desktop logs) and are controlled by the client's log level.
Usage in tools:
func handleMyTool(ctx context.Context, req *mcp.CallToolRequest, args MyToolArgs) (*mcp.CallToolResult, any, error) {
// Create MCP logger that sends logs to the client.
logger := slog.New(mcp.NewLoggingHandler(req.Session, nil))
logger.Info("processing started", "param", args.Param)
logger.Debug("detailed info", "value", someValue)
logger.Warn("potential issue", "reason", "something unexpected")
// ... tool implementation ...
}How it works:
- The client controls the log level via the
SetLoggingLevelMCP notification - Only logs at or above the client's level are sent over the protocol
- Logs appear in the client's logging UI (not in server logs)
- Log levels: debug, info, notice, warning, error, critical, alert, emergency
Key differences:
| Feature | Server Logging | MCP Client Logging |
|---|---|---|
| Audience | Server operators | Client users/developers |
| Output | stdout/stderr | MCP protocol notifications |
| Control | -debug flag |
Client's SetLoggingLevel |
| Format | JSON to files | JSON over protocol |
| Use case | Debugging server | Debugging tool execution |
Server-side logs are emitted as JSON objects:
{"time":"2024-01-15T10:30:45.123Z","level":"INFO","msg":"Starting HTTP server","addr":"127.0.0.1:8080"}
{"time":"2024-01-15T10:30:45.456Z","level":"ERROR","msg":"server failed","error":"connection refused"}With debug logging enabled, source information is included:
{"time":"2024-01-15T10:30:45.789Z","level":"DEBUG","source":{"file":"main.go","line":150},"msg":"processing request"}The server logger is initialized in main.go and set as the default slog logger. Use it for operational logging:
import "log/slog"
// Info level
slog.Info("operation completed", "key", "value")
// Error level with structured fields
slog.Error("operation failed", "error", err, "context", "additional info")
// Debug level (only shown when debug mode is enabled)
slog.Debug("detailed diagnostic", "request_id", reqID)
// Using logger with context
logger.With("component", "tool_handler").Info("processing tool call")const errKey = "error"
// Server-side error logging
logger.With(errKey, err).Error("operation failed")
// MCP client error logging (in tools)
mcpLogger.Error("tool operation failed", "error", err)Recommendation: Use MCP client logging in tools for visibility to end users, and server-side logging for operational concerns.
The MCP Go SDK provides a simple pattern for adding tools. Tools are implemented in the internal/tools package and registered with the server. Each Register<ToolName> function calls mcp.AddTool directly. Scope enforcement happens exclusively at registration time in newServer() — tools the caller cannot invoke are simply not registered for that request and therefore never appear in tools/list.
Two scope constants are defined in internal/tools/scopes.go:
| Constant | Value | Used for |
|---|---|---|
ScopeRead |
read:all |
Tools with ReadOnlyHint: true |
ScopeManage |
manage:all |
Tools where ReadOnlyHint is false (the default) |
newServer() computes two booleans from the caller's JWT scopes and gates each tool registration on the appropriate one:
canManage— true when the token holdsmanage:all.canRead— true whencanManageis true or the token holdsread:all. Amanage:alltoken implicitly has read access.
In stdio mode (no auth token), both flags are true and all enabled tools are registered without restriction.
- Create a new file in
internal/tools/(e.g.,my_tool.go) - Define the input struct with JSON schema tags
- Implement the handler function with tool logic
- Create a registration function calling
mcp.AddTooldirectly - Call the registration function in
main.go, gated on the appropriate scope boolean (canReadorcanManage)
File: internal/tools/my_tool.go (read-only tool)
// Copyright The Linux Foundation and contributors.
// SPDX-License-Identifier: MIT
package tools
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// MyToolArgs defines the input parameters for the my_tool tool.
type MyToolArgs struct {
Param1 string `json:"param1" jsonschema:"Description of parameter 1"`
Param2 int `json:"param2,omitempty" jsonschema:"Optional parameter 2"`
}
// RegisterMyTool registers the my_tool tool with the MCP server.
func RegisterMyTool(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "my_tool",
Description: "Brief description of what the tool does",
Annotations: &mcp.ToolAnnotations{
Title: "My Tool",
ReadOnlyHint: true,
},
}, handleMyTool)
}
// handleMyTool implements the my_tool tool logic.
func handleMyTool(ctx context.Context, req *mcp.CallToolRequest, args MyToolArgs) (*mcp.CallToolResult, any, error) {
result := fmt.Sprintf("Processed: %s with value %d", args.Param1, args.Param2)
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: result},
},
}, nil, nil
}For a write tool (where ReadOnlyHint is false / unset):
func RegisterMyWriteTool(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "create_thing",
Description: "Create a new thing",
Annotations: &mcp.ToolAnnotations{
Title: "Create Thing",
// DestructiveHint: always set explicitly on write tools.
// false for create operations; true for update and delete.
DestructiveHint: boolPtr(false),
},
}, handleCreateThing)
}Register in cmd/lfx-mcp-server/main.go:
import (
"github.com/linuxfoundation/lfx-mcp/internal/tools"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func runStdioServer() {
// ... server setup ...
// Register tools.
tools.RegisterHelloWorld(server)
tools.RegisterMyTool(server) // Add your new tool
// ... run server ...
}The MCP Go SDK uses jsonschema struct tags for automatic schema generation:
type ToolArgs struct {
Required string `json:"required" jsonschema:"This parameter is required"`
Optional *string `json:"optional,omitempty" jsonschema:"This parameter is optional"`
Number int `json:"number" jsonschema:"A numeric parameter"`
WithEnum string `json:"status" jsonschema:"enum=active,inactive,pending"`
}MCP supports various content types in tool responses:
// Text content
&mcp.TextContent{Text: "Plain text response"}
// Multiple content items
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "First part"},
&mcp.TextContent{Text: "Second part"},
},
}, nil, nilAll tools should include a mcp.ToolAnnotations struct to provide metadata hints to MCP clients (e.g., Claude). Annotations help clients decide how to present tools and whether to confirm before calling them.
boolPtr := func(v bool) *bool { return &v }
Annotations: &mcp.ToolAnnotations{
Title: "Human Readable Title",
ReadOnlyHint: true, // True if the tool makes no mutations.
// DestructiveHint: boolPtr(false), // Set when ReadOnlyHint is false and the tool is non-destructive.
// OpenWorldHint: boolPtr(false), // Override only for truly closed-world tools (see below).
},ReadOnlyHint (bool, default false) is the most impactful annotation — clients use it to decide whether to auto-confirm tool calls. Set it to true for any tool that only reads data and has no side effects.
DestructiveHint (*bool, default true) is only meaningful when ReadOnlyHint is false. Set it to false for write tools that are additive or non-destructive (e.g., creating a new resource vs. deleting one).
OpenWorldHint (*bool, default true) signals whether the tool interacts with an external, stateful system. The key distinction is not whether you own the API — it's whether the environment is fully controlled and deterministic:
-
Set to
true(or omit, since it's the default) for any tool that calls an external API, including LFX's own services. Even for APIs we own, results can change between calls as data mutates on the server, network failures are possible, and write operations have real-world side effects. The tool doesn't fully control what it's reading or modifying, so the world is open. -
Set to
falseonly for tools that are genuinely closed-world: pure in-process computations, static config lookups that never change, or in-memory operations with no network calls. These are the exception, not the rule.
Focus annotation effort on ReadOnlyHint and DestructiveHint — those have the most impact on client behavior. For OpenWorldHint, the default of true is correct for virtually all LFX API tools; only override it when you are certain the tool has zero external interaction.
Test the server by sending JSON-RPC messages. hello_world is not in defaultTools, so enable it explicitly:
# Initialize and call tool
(echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}';
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"hello_world","arguments":{"name":"Test"}}}';
sleep 0.5) | LFXMCP_TOOLS=hello_world ./bin/lfx-mcp-server -mode=stdio# Start the server, enabling only hello_world.
LFXMCP_TOOLS=hello_world ./bin/lfx-mcp-server -mode=http &
# Call the tool.
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hello_world","arguments":{"name":"Test"}}}'Responses are returned as Server-Sent Events (SSE) with event: message and data: fields.
The scripts/test_server.sh script provides comprehensive testing:
./scripts/test_server.shThis tests:
- Server initialization
- Tool discovery (
tools/list) - Tool execution with various parameters
- Error handling
| Target | Description |
|---|---|
all |
Clean, check, and build (default) |
build |
Compile the binary |
clean |
Remove build artifacts |
fmt |
Format Go code |
vet |
Run go vet |
lint |
Run golangci-lint |
check |
Run fmt, vet, and lint |
run |
Build and run in stdio mode |
test |
Run Go tests |
test-coverage |
Run tests with coverage |
deps |
Download and tidy dependencies |
install-tools |
Install development tools |
The Makefile uses optimized build flags:
-ldflags="-s -w"for smaller binaries- Output to
./bin/lfx-mcp-server
The server supports configuration via environment variables with the LFXMCP_ prefix. Environment variables override command-line flags.
| Flag | Env Var | Default | Description |
|---|---|---|---|
-mode |
LFXMCP_MODE |
stdio |
Transport mode: stdio or http |
-http.host |
LFXMCP_HTTP_HOST |
127.0.0.1 |
HTTP server bind address |
-http.port |
LFXMCP_HTTP_PORT |
8080 |
HTTP server port |
-debug |
LFXMCP_DEBUG |
false |
Enable debug logging with source locations |
-debug_traffic |
LFXMCP_DEBUG_TRAFFIC |
false |
Log outbound LFX API request/response bodies |
-tools |
LFXMCP_TOOLS |
defaultTools |
Comma-separated list of tools to enable |
-committees_as_groups |
LFXMCP_COMMITTEES_AS_GROUPS |
false |
Rebrand committee tools to use "group" terminology (feature flag) |
-mcp_api.auth_servers |
LFXMCP_MCP_API_AUTH_SERVERS |
— | OAuth authorization server URLs (comma-separated) |
-mcp_api.public_url |
LFXMCP_MCP_API_PUBLIC_URL |
— | Public URL for MCP API (OAuth PRM) |
-mcp_api.scopes |
LFXMCP_MCP_API_SCOPES |
— | OAuth scopes (comma-separated) |
-client_id |
LFXMCP_CLIENT_ID |
— | OAuth client ID for token exchange |
-client_secret |
LFXMCP_CLIENT_SECRET |
— | OAuth client secret |
-client_assertion_signing_key |
LFXMCP_CLIENT_ASSERTION_SIGNING_KEY |
— | PEM-encoded RSA private key for client assertion (RFC 7523) |
-token_endpoint |
LFXMCP_TOKEN_ENDPOINT |
— | OAuth2 token endpoint URL (RFC 8693) |
-lfx_api_url |
LFXMCP_LFX_API_URL |
— | LFX API base URL (token exchange audience) |
-onboarding_api_url |
LFXMCP_ONBOARDING_API_URL |
— | Base URL of the member onboarding service |
-onboarding_api_audience |
LFXMCP_ONBOARDING_API_AUDIENCE |
— | Auth0 resource server audience for the member onboarding API |
-lens_api_url |
LFXMCP_LENS_API_URL |
— | Base URL of the LFX Lens service |
-lens_api_audience |
LFXMCP_LENS_API_AUDIENCE |
— | Auth0 resource server audience for the LFX Lens API |
// Return error in tool result (not JSON-RPC error)
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Error: " + err.Error()},
},
IsError: true,
}, nil, nil
// Return JSON-RPC error for invalid requests
return nil, nil, fmt.Errorf("invalid parameter: %s", param)The SDK handles most protocol-level errors automatically. Tool implementation should focus on business logic errors.
github.com/modelcontextprotocol/go-sdk- Official MCP Go SDKgithub.com/google/jsonschema-go- JSON schema generation (indirect)
golangci-lint- Code linting (optional)jq- JSON processing for tests
- Add Tools: Create new tools in
internal/tools/following the established pattern - Tool Organization: One tool per file (e.g.,
hello_world.go,my_tool.go) - Registration Pattern: Each tool should have a
Register<ToolName>(server)function that callsmcp.AddTooldirectly — never use wrapper functions for scope enforcement - Schema Tags: Always include descriptive
jsonschematags - Testing: Test new tools with the test script (
./scripts/test_server.sh) - Documentation: Update README.md for user-facing changes
- Code Quality: Run
make checkbefore commits - Package Comments: Every new
*.gofile must include a// Package <name> ...doc comment immediately above thepackagedeclaration (required by Megalinter's revivepackage-commentsrule)
Releases follow semantic versioning (vMAJOR.MINOR.PATCH). The current series is v0.x; do not increment the major version unless explicitly instructed.
| Change type | Version component |
|---|---|
| Bug fixes, tool description/schema wording tweaks, operational changes (Helm, CI) | patch |
| New tools or substantial updates to existing tools | minor |
| Breaking changes or explicit instruction | major (only when told) |
Do not create or push git tags manually. Instead, use the GitHub Releases UI (or gh CLI) to create a release; GitHub will create the tag automatically.
Steps via gh:
# Determine the next version by inspecting the latest tag.
LATEST=$(git tag --sort=-v:refname | head -1) # e.g. v0.7.6
echo "Latest tag: $LATEST"
# Bump appropriately from the latest tag (patch example):
NEXT=v0.7.7
gh release create "$NEXT" \
--generate-notes \
--latest--generate-notesautomatically generates release notes from merged PRs since the previous tag.--latestmarks the release as the latest on the GitHub Releases page.
No additional manual steps (e.g. building binaries, updating a changelog file) are required before creating the release. After creating the release, verify that the GitHub Actions Publish Tagged Release workflow triggered by the new tag completes successfully; otherwise release artifacts such as published images/charts may be missing even though the GitHub Release exists.