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 1.26.0+
- Protocol: Model Context Protocol (MCP) 2024-11-05
- SDK: Official MCP Go SDK v1.5.0+
- 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/
│ └── tools/ # MCP tool implementations
├── 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.
# Ensure Go 1.26.0+ is installed
go version # Should show go version go1.26.0 or latermake 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:
# 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) | ./bin/lfx-mcp-server stdioThe 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
// Request
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
// Response
{
"jsonrpc":"2.0",
"id":2,
"result":{
"tools":[
{
"name":"hello_world",
"description":"A simple hello world tool...",
"inputSchema":{
"type":"object",
"properties":{...}
}
}
]
}
}// Request
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"hello_world","arguments":{"name":"LFX"}}}
// Response
{
"jsonrpc":"2.0",
"id":3,
"result":{
"content":[
{"type":"text","text":"Hello, LFX!"}
]
}
}| 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 variable names use underscores, which are automatically transformed to dots for nested configuration keys (e.g., LFXMCP_HTTP_PORT becomes http.port).
Configuration Precedence: Environment variables override command-line flags. This allows command-line flags to provide defaults while environment variables can override them in containerized deployments.
| Variable | Description | Default | Required |
|---|---|---|---|
LFXMCP_MODE |
Transport mode (stdio or http) |
stdio | No |
LFXMCP_HTTP_HOST |
HTTP server host | 127.0.0.1 | No |
LFXMCP_HTTP_PORT |
HTTP server port | 8080 | No |
LFXMCP_HTTP_PUBLIC_URL |
Public URL for HTTP transport | - | No |
LFXMCP_DEBUG |
Enable debug logging | false | No |
LFXMCP_DEBUG_TRAFFIC |
Enable HTTP request/response wire logging for outbound LFX API calls | false | No |
LFXMCP_TOOLS |
Comma-separated list of tools to enable | - | No |
LFXMCP_MCP_API_AUTH_SERVERS |
Comma-separated list of authorization server URLs | - | No |
LFXMCP_MCP_API_PUBLIC_URL |
Public URL for MCP API (for OAuth PRM) | - | No |
LFXMCP_MCP_API_SCOPES |
OAuth scopes as comma-separated list | - | No |
LFXMCP_CLIENT_ID |
OAuth client ID for authentication | - | No |
LFXMCP_CLIENT_SECRET |
OAuth client secret | - | No |
LFXMCP_CLIENT_ASSERTION_SIGNING_KEY |
PEM-encoded RSA private key for client assertion | - | No |
LFXMCP_TOKEN_ENDPOINT |
OAuth2 token endpoint URL for token exchange | - | No |
LFXMCP_LFX_API_URL |
LFX API URL (used as token exchange audience) | - | No |
LFXMCP_ONBOARDING_API_URL |
Base URL of the member onboarding service | - | No |
LFXMCP_ONBOARDING_API_AUDIENCE |
Auth0 resource server audience for the member onboarding API | - | No |
LFXMCP_LENS_API_URL |
Base URL of the LFX Lens service | - | No |
LFXMCP_LENS_API_AUDIENCE |
Auth0 resource server audience for the LFX Lens API | - | No |
Example:
export LFXMCP_MODE=http
export LFXMCP_HTTP_PORT=8080
export LFXMCP_DEBUG=true
export LFXMCP_TOOLS=hello_world,user_info
export LFXMCP_MCP_API_AUTH_SERVERS=https://linuxfoundation-dev.auth0.com
export LFXMCP_CLIENT_ID=your_client_id
export LFXMCP_TOKEN_ENDPOINT=https://linuxfoundation-dev.auth0.com/oauth/token
export LFXMCP_LFX_API_URL=https://lfx-api.dev.v2.cluster.linuxfound.info/
./bin/lfx-mcp-server// 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.
- Server Logs: Use log statements in tool handlers
- JSON Validation: Ensure JSON-RPC messages are properly formatted
- Schema Validation: Check that input matches generated schema
- Manual Testing: Use the test script for quick validation
Add debug logging to tools:
func(ctx context.Context, req *mcp.CallToolRequest, args MyToolArgs) (*mcp.CallToolResult, any, error) {
log.Printf("Tool called with args: %+v", args)
// Tool implementation
}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.
The skeleton is designed for easy extension with LFX-specific tools:
- Project Management: Create/search/update projects
- Committee Management: Committee and committee member management
- Meeting Management: Meetings scheduling and participant management
- Mailing List Management: Meetings creation and management
- Membership operations: Get project members and key contacts
Each new tool should follow the established patterns and maintain the clean, simple architecture.