Skip to content

Per-server oauth flow #20

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ mcp-front is a Go-based OAuth 2.1 proxy server for MCP (Model Context Protocol)
- **Define interfaces where they are used** - Not in the package that implements them
- **Avoid circular imports** - Use interface segregation in separate packages when needed
- **Dependency injection over getter methods** - Pass dependencies to constructors
- **Functional core, imperative shell** - Prefer pure functions for business logic, keep side effects (I/O, state mutations) at the boundaries. Makes code more testable and reasoning easier.
- **Upstream lifecycle control** - Manage goroutines, servers, and background processes from the application root. Library code should expose Start/Stop methods, not start things autonomously.

### 🎯 Core Development Principles (from Zig Zen)

Expand Down Expand Up @@ -153,6 +155,7 @@ cmd/mcp-front/ # Main application entry point
3. Don't create new auth patterns - use existing OAuth or bearer token auth
4. Don't modify git configuration
5. Don't create README files proactively
6. **Variable shadowing package names** - `config.MCPClientConfig is not a type` means a variable named `config` is shadowing the package. Always check for variables that shadow imported package names

### When Working on Features

Expand Down
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ doc:

format:
go fmt ./...
# go run golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest -fix -test ./...
modernize -fix -test ./...
cd docs-site && npm run format

lint:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ Some MCP servers are better to use with each users having their own integration
"notion": {
"transportType": "stdio",
"requiresUserToken": true,
"tokenSetup": {
"userAuthentication": {
"type": "manual",
"displayName": "Notion Integration Token",
"instructions": "Create an integration and copy the token",
"helpUrl": "https://www.notion.so/my-integrations"
Expand Down
14 changes: 11 additions & 3 deletions cmd/mcp-front/main.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package main

import (
"context"
"encoding/json"
"flag"
"fmt"
"os"

"github.com/dgellow/mcp-front/internal"
"github.com/dgellow/mcp-front/internal/config"
"github.com/dgellow/mcp-front/internal/log"
"github.com/dgellow/mcp-front/internal/server"
)

var BuildVersion = "dev"
Expand Down Expand Up @@ -151,12 +152,19 @@ func main() {
os.Exit(1)
}

log.LogInfoWithFields("main", "Starting mcp-front", map[string]interface{}{
log.LogInfoWithFields("main", "Starting mcp-front", map[string]any{
"version": BuildVersion,
"config": *conf,
})

err = server.Run(cfg)
ctx := context.Background()
mcpFront, err := internal.NewMCPFront(ctx, cfg)
if err != nil {
log.LogError("Failed to create MCP proxy: %v", err)
os.Exit(1)
}

err = mcpFront.Run()
if err != nil {
log.LogError("Failed to start server: %v", err)
os.Exit(1)
Expand Down
3 changes: 2 additions & 1 deletion config-oauth.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"notion": {
"transportType": "stdio",
"requiresUserToken": true,
"tokenSetup": {
"userAuthentication": {
"type": "manual",
"displayName": "Notion Integration Token",
"instructions": "Create an integration at https://www.notion.so/my-integrations and copy the token",
"helpUrl": "https://developers.notion.com/docs/create-a-notion-integration"
Expand Down
6 changes: 4 additions & 2 deletions config-user-tokens-example.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"OPENAPI_MCP_HEADERS": {"$userToken": "{\"Authorization\": \"Bearer {{token}}\"}"}
},
"requiresUserToken": true,
"tokenSetup": {
"userAuthentication": {
"type": "manual",
"displayName": "Notion API Token",
"instructions": "Enter your Notion API token. You can find this at https://www.notion.so/my-integrations",
"helpUrl": "https://developers.notion.com/docs/authorization",
Expand All @@ -43,7 +44,8 @@
"GITHUB_TOKEN": {"$userToken": "{{token}}"}
},
"requiresUserToken": true,
"tokenSetup": {
"userAuthentication": {
"type": "manual",
"displayName": "GitHub Personal Access Token",
"instructions": "Create a personal access token at https://github.com/settings/tokens",
"helpUrl": "https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token",
Expand Down
3 changes: 2 additions & 1 deletion docs-site/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,8 @@ Use the `options` field for additional configuration:
"X-API-Key": { "$env": "DB_API_KEY" }
},
"requiresUserToken": true,
"tokenSetup": {
"userAuthentication": {
"type": "manual",
"displayName": "Database Token",
"instructions": "Get your token from the admin panel",
"helpUrl": "https://db.company.com/tokens"
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/mark3labs/mcp-go v0.28.0
github.com/ory/fosite v0.42.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.38.0
golang.org/x/oauth2 v0.30.0
google.golang.org/api v0.214.0
google.golang.org/grpc v1.67.3
Expand Down Expand Up @@ -61,7 +62,6 @@ require (
go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/metric v1.29.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
Expand Down
42 changes: 42 additions & 0 deletions integration/cli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package integration

import (
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCLIConfigInitGeneratesValidConfig(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "generated-config.json")

t.Run("generate and validate config", func(t *testing.T) {
// Step 1: Generate config with -config-init
cmd := exec.Command("../cmd/mcp-front/mcp-front", "-config-init", configPath)
output, err := cmd.CombinedOutput()

t.Logf("config-init output: %s", output)

require.NoError(t, err, "config-init should succeed")
assert.Contains(t, string(output), "Generated default config at:", "should report generation")

// Verify file was created
fi, err := os.Stat(configPath)
require.NoError(t, err, "config file should exist")
require.Greater(t, fi.Size(), int64(0), "config file should not be empty")

// Step 2: Validate the generated config
cmd = exec.Command("../cmd/mcp-front/mcp-front", "-config", configPath, "-validate")
output, err = cmd.CombinedOutput()

t.Logf("validate output: %s", output)

// The generated config should be valid
require.NoError(t, err, "validate should succeed for config-init generated file")
assert.Contains(t, string(output), "Result: PASS", "validation should pass")
})
}
8 changes: 5 additions & 3 deletions integration/config/config.oauth-token-test.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,20 @@
"transportType": "sse",
"url": "https://notion-mcp.example.com",
"requiresUserToken": true,
"tokenSetup": {
"userAuthentication": {
"type": "manual",
"displayName": "Notion",
"instructions": "Create a Notion integration token",
"helpUrl": "https://developers.notion.com",
"tokenFormat": "^secret_[a-zA-Z0-9]{43}$"
"validation": "^secret_[a-zA-Z0-9]{43}$"
}
},
"github": {
"transportType": "sse",
"url": "https://github-mcp.example.com",
"requiresUserToken": true,
"tokenSetup": {
"userAuthentication": {
"type": "manual",
"displayName": "GitHub",
"instructions": "Create a GitHub personal access token",
"helpUrl": "https://github.com/settings/tokens"
Expand Down
3 changes: 2 additions & 1 deletion integration/config/config.oauth-usertoken-tools-test.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"USER_TOKEN": {"$userToken": "{{token}}"}
},
"requiresUserToken": true,
"tokenSetup": {
"userAuthentication": {
"type": "manual",
"displayName": "Test Service",
"instructions": "Enter your test token",
"helpUrl": "https://example.com/help"
Expand Down
56 changes: 28 additions & 28 deletions integration/inline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ func TestInlineMCPServer(t *testing.T) {

// Test 1: Basic echo tool (static args)
t.Run("echo tool", func(t *testing.T) {
params := map[string]interface{}{
params := map[string]any{
"name": "echo",
"arguments": map[string]interface{}{
"arguments": map[string]any{
"message": "Hello, inline MCP!",
},
}
Expand All @@ -47,18 +47,18 @@ func TestInlineMCPServer(t *testing.T) {
require.NoError(t, err, "Failed to call echo tool")

// Check for error in response
errorMap, hasError := result["error"].(map[string]interface{})
errorMap, hasError := result["error"].(map[string]any)
assert.False(t, hasError, "Echo tool returned error: %v", errorMap)

// Verify result
resultMap, ok := result["result"].(map[string]interface{})
resultMap, ok := result["result"].(map[string]any)
require.True(t, ok, "Expected result in response")

content, ok := resultMap["content"].([]interface{})
content, ok := resultMap["content"].([]any)
require.True(t, ok, "Expected content in result")
require.NotEmpty(t, content, "Expected content array")

firstContent, ok := content[0].(map[string]interface{})
firstContent, ok := content[0].(map[string]any)
require.True(t, ok, "Expected content item to be map")

text, ok := firstContent["text"].(string)
Expand All @@ -69,18 +69,18 @@ func TestInlineMCPServer(t *testing.T) {

// Test 2: Environment variables
t.Run("environment variables", func(t *testing.T) {
params := map[string]interface{}{
params := map[string]any{
"name": "env_test",
"arguments": map[string]interface{}{},
"arguments": map[string]any{},
}

result, err := client.SendMCPRequest("tools/call", params)
require.NoError(t, err, "Failed to call env_test tool")

// Check result
resultMap, _ := result["result"].(map[string]interface{})
content, _ := resultMap["content"].([]interface{})
firstContent, _ := content[0].(map[string]interface{})
resultMap, _ := result["result"].(map[string]any)
content, _ := resultMap["content"].([]any)
firstContent, _ := content[0].(map[string]any)
text, _ := firstContent["text"].(string)

// printenv outputs all environment variables
Expand All @@ -90,28 +90,28 @@ func TestInlineMCPServer(t *testing.T) {

// Test 3: Static output test
t.Run("static output", func(t *testing.T) {
params := map[string]interface{}{
params := map[string]any{
"name": "static_test",
"arguments": map[string]interface{}{},
"arguments": map[string]any{},
}

result, err := client.SendMCPRequest("tools/call", params)
require.NoError(t, err, "Failed to call static_test tool")

// Check result
resultMap, _ := result["result"].(map[string]interface{})
content, _ := resultMap["content"].([]interface{})
firstContent, _ := content[0].(map[string]interface{})
resultMap, _ := result["result"].(map[string]any)
content, _ := resultMap["content"].([]any)
firstContent, _ := content[0].(map[string]any)
text, _ := firstContent["text"].(string)

assert.Contains(t, text, "Static output: test")
})

// Test 4: JSON output parsing
t.Run("JSON output", func(t *testing.T) {
params := map[string]interface{}{
params := map[string]any{
"name": "json_output",
"arguments": map[string]interface{}{
"arguments": map[string]any{
"value": "test-input",
},
}
Expand All @@ -120,9 +120,9 @@ func TestInlineMCPServer(t *testing.T) {
require.NoError(t, err, "Failed to call json_output tool")

// For JSON output, the content should be parsed as JSON
resultMap, _ := result["result"].(map[string]interface{})
content, _ := resultMap["content"].([]interface{})
firstContent, _ := content[0].(map[string]interface{})
resultMap, _ := result["result"].(map[string]any)
content, _ := resultMap["content"].([]any)
firstContent, _ := content[0].(map[string]any)

// The JSON output should be in the text field as a string
text, ok := firstContent["text"].(string)
Expand All @@ -135,16 +135,16 @@ func TestInlineMCPServer(t *testing.T) {

// Test 6: Error handling
t.Run("failing tool", func(t *testing.T) {
params := map[string]interface{}{
params := map[string]any{
"name": "failing_tool",
"arguments": map[string]interface{}{},
"arguments": map[string]any{},
}

result, err := client.SendMCPRequest("tools/call", params)
require.NoError(t, err, "Request should succeed even if tool fails")

// Check for error in response
errorMap, hasError := result["error"].(map[string]interface{})
errorMap, hasError := result["error"].(map[string]any)
assert.True(t, hasError, "Expected error for failing tool")

if hasError {
Expand All @@ -158,21 +158,21 @@ func TestInlineMCPServer(t *testing.T) {

// Test 7: List tools
t.Run("list tools", func(t *testing.T) {
result, err := client.SendMCPRequest("tools/list", map[string]interface{}{})
result, err := client.SendMCPRequest("tools/list", map[string]any{})
require.NoError(t, err, "Failed to list tools")

// Check result
resultMap, ok := result["result"].(map[string]interface{})
resultMap, ok := result["result"].(map[string]any)
require.True(t, ok, "Expected result in response")

tools, ok := resultMap["tools"].([]interface{})
tools, ok := resultMap["tools"].([]any)
require.True(t, ok, "Expected tools array")
assert.Len(t, tools, 6, "Expected 6 tools")

// Verify tool names
toolNames := make([]string, 0)
for _, tool := range tools {
toolMap, _ := tool.(map[string]interface{})
toolMap, _ := tool.(map[string]any)
name, _ := toolMap["name"].(string)
toolNames = append(toolNames, name)
}
Expand Down
Loading
Loading