This project demonstrates a Model Context Protocol (MCP) server written using the official Go SDK (modelcontextprotocol/go-sdk). It integrates Open Policy Agent (OPA) to enforce security and compliance rules on tool calls.
- Transport: JSON-RPC over Stdio
- SDK:
github.com/modelcontextprotocol/go-sdk - Policy Engine: OPA (
github.com/open-policy-agent/opa) running in-process - Logging: Structured logging with
log/slog(JSON and text formats)
The project follows Go best practices with a clear separation of concerns:
main.go- Application entry point, CLI flag parsing, and initializationinternal/- Internal packages (not importable by external projects)config/- Configuration loading and managementpolicy/- OPA policy enforcement and middlewareserver/- MCP server setup and tool registrationtools/- Tool implementations (http_get, echo)
pkg/- Public packages (can be imported by other projects)logger/- Structured logging configuration
.
├── main.go # Application entry point
├── config.json # Server and tool configuration
├── config-minimal.json # Example minimal configuration
├── policy.rego # OPA policy rules
├── internal/ # Internal packages
│ ├── config/ # Configuration management
│ │ ├── config.go
│ │ └── config_test.go
│ ├── policy/ # Policy enforcement
│ │ ├── policy.go
│ │ └── policy_test.go
│ ├── mcp_server/ # MCP server implementation
│ │ └── server.go
│ └── tools/ # Tool definitions and handlers
│ ├── tools.go
│ └── tools_test.go
├── pkg/ # Public packages
│ └── logger/ # Structured logging
│ ├── logger.go
│ └── logger_test.go
└── .github/
└── workflows/
└── go.yml # CI/CD workflow
When an LLM calls a tool (e.g., http_get):
- The
Enforcegeneric middleware intercepts the call - It converts the strongly-typed input struct (e.g.,
GetInput) into a map - It sends the tool name and arguments to the OPA engine
- OPA evaluates
policy.rego - If
allowisfalse, the tool execution is skipped, and an error is returned to the LLM
- Go (see
go.modfor minimum version)
The module is already initialized. To install dependencies:
go mod tidy(Optional) Customize the configuration by editing config.json to enable/disable tools or change server settings.
# Run with default policy file (policy.rego)
go run main.go
# Or use the compiled binary
./mcp-server-example# Specify a custom policy file
go run main.go -policy /path/to/custom-policy.rego
# Specify a custom configuration file
go run main.go -config /path/to/custom-config.json
# Use both custom config and policy
go run main.go -config config-minimal.json -policy policy.rego
# Use JSON logging for production
go run main.go -log-format json -log-level info
# Debug mode with detailed logging
go run main.go -log-level debug
# Quiet mode (warnings and errors only)
go run main.go -log-level warn
# Show help
go run main.go -hAvailable flags:
-config- Path to the server configuration file (default:config.json)-policy- Path to the OPA policy file (default:policy.rego)-log-format- Log format:textorjson(default:text)-log-level- Log level:debug,info,warn,error(default:info)-metrics-port- Port for Prometheus metrics endpoint (default:9090)
The server will start and listen for MCP protocol messages on stdin/stdout.
The server uses structured logging with slog (Go's standard structured logging library).
Text format (default):
./mcp-server-example
# Output:
# time=2025-12-05T15:20:24.004+01:00 level=INFO msg="Loading configuration" file=config.json
# time=2025-12-05T15:20:24.006+01:00 level=INFO msg="Creating MCP server" name=SecureGoMCP version=1.0.0JSON format (for log aggregation):
./mcp-server-example -log-format json
# Output:
# {"time":"2025-12-05T15:21:15.131+01:00","level":"INFO","msg":"Loading configuration","file":"config.json"}
# {"time":"2025-12-05T15:21:15.133+01:00","level":"INFO","msg":"Creating MCP server","name":"SecureGoMCP","version":"1.0.0"}Log levels:
debug- Detailed debugging informationinfo- General informational messages (default)warn- Warning messages and policy blockserror- Error messages only
# Only show warnings and errors
./mcp-server-example -log-level warn
# Show all debug information
./mcp-server-example -log-level debug
# JSON format with debug level
./mcp-server-example -log-format json -log-level debugThe server exposes Prometheus metrics on an HTTP endpoint for monitoring tool calls, policy evaluations, and performance.
By default, metrics are available at http://localhost:9090/metrics. You can change the port with the -metrics-port flag.
# Start server with custom metrics port
./mcp-server-example -metrics-port 8080
# In another terminal, query metrics
curl http://localhost:8080/metricsRequest Metrics:
mcp_requests_total- Total number of MCP requests received
Tool Call Metrics:
mcp_tool_calls_total{tool="..."}- Total number of tool calls by tool namemcp_tool_call_duration_seconds{tool="..."}- Duration of tool calls in seconds (histogram)
Policy Engine Metrics:
mcp_policy_evaluations_total{tool="...", result="allowed|denied"}- Total policy evaluations by resultmcp_policy_evaluation_duration_seconds- Duration of policy evaluations in seconds (histogram)mcp_policy_denials_total{tool="...", reason="..."}- Total policy denials by tool and reasonmcp_policy_errors_total- Total number of policy evaluation errors
# Rate of tool calls per second
rate(mcp_tool_calls_total[5m])
# Policy denial rate
rate(mcp_policy_evaluations_total{result="denied"}[5m])
# Average policy evaluation time
rate(mcp_policy_evaluation_duration_seconds_sum[5m]) / rate(mcp_policy_evaluation_duration_seconds_count[5m])
# 95th percentile tool call duration
histogram_quantile(0.95, rate(mcp_tool_call_duration_seconds_bucket[5m]))
You can create a Grafana dashboard to visualize these metrics. Import the metrics into Prometheus and configure Grafana to query your Prometheus instance.
otelite is a lightweight, single-binary OpenTelemetry receiver with a built-in web dashboard and terminal UI. It stores traces, metrics, and logs in SQLite — no Jaeger, Prometheus, or other infrastructure required.
# macOS (Homebrew)
brew install planetf1/tap/otelite
# Or with Cargo
cargo install oteliteStart the otelite server (receives OTLP on ports 4317/4318, dashboard on port 3000):
otelite serveIn a separate terminal, run the MCP server (or use the Inspector):
go run main.goOpen http://localhost:3000 in your browser to view traces, metrics, and logs.
# List recent traces
otelite traces list
# Show trace details
otelite traces show <trace-id>
# List recent logs
otelite logs list --severity ERROR --since 1h
# Search logs
otelite logs search "policy"
# List metrics
otelite metrics list
# Launch the terminal UI
otelite tuiBy default the MCP server sends OTLP data to localhost:4318. To use a different collector, set the standard environment variable:
OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4320 go run main.goThe server configuration is defined in a JSON file that specifies server metadata and available tools:
{
"server": {
"name": "SecureGoMCP",
"version": "1.0.0"
},
"tools": [
{
"name": "http_get",
"description": "Fetch a website. Subject to strict domain policies.",
"handler": "http_get"
},
{
"name": "echo",
"description": "Echo a message back.",
"handler": "echo"
}
]
}Configuration fields:
server.name- Name of the MCP serverserver.version- Version of the MCP servertools- Array of tool definitionsname- Tool name (must be unique)description- Tool description shown to clientshandler- Handler function name (http_getorecho)
You can create custom configuration files to enable/disable tools or change server metadata without modifying code.
The config file is validated at startup against internal/config/config.schema.json
(embedded into the binary), with clear validation errors if required fields
are missing or malformed. You can also place a config.schema.json next to
your config file to override the embedded schema.
You can use the official MCP Inspector to test the policies:
# With default policy
npx @modelcontextprotocol/inspector go run main.go
# With custom policy
npx @modelcontextprotocol/inspector go run main.go -policy custom-policy.rego- Tool:
http_get - Args:
{"url": "https://example.com"} - Result: Success (domain is whitelisted)
- Tool:
http_get - Args:
{"url": "https://malicious.com"} - Result: Error - "Blocked: URL is not in the allowed whitelist..."
- Tool:
echo - Args:
{"message": "I want to hack the mainframe"} - Result: Error - "Blocked: Content contains prohibited keywords."
Simulates an HTTP GET request with domain whitelisting.
Allowed domains (defined in policy.rego):
example.comgoogle.comapi.internal.corp
Echoes a message back with content filtering.
Blocked keywords (defined in policy.rego):
hackignore instructionsbypass
Edit policy.rego to customize:
- Allowed domains for
http_get - Prohibited keywords for
echo - Add new policy rules for additional tools
See examples/policies/ for ready-to-copy policy patterns, including:
- Tool allowlists and argument constraints
- Role-based access control
- Rate limiting (with external data or input context)
Each example includes notes about any extra input fields you may need to pass to OPA.
This project uses GitHub Actions for CI/CD. The workflow automatically:
- Validates Go build
- Runs tests with race detection and coverage reporting
- Performs code quality checks:
go vet- examines Go source code and reports suspicious constructsgo fmt- checks code formattingstaticcheck- advanced static analysis
The CI workflow runs on:
- Push to
mainordevelopbranches - Pull requests targeting
mainordevelopbranches
# Run all tests
go test -v ./...
# Run tests with race detection and coverage
go test -v -race -coverprofile=coverage.out ./...
# View coverage report
go tool cover -html=coverage.outThe OPA policy has unit tests in policy_test.rego.
# Run policy tests
opa test -v policy.rego policy_test.rego# Run go vet
go vet ./...
# Check formatting
gofmt -s -l .
# Install and run staticcheck
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...