Skip to content
Open
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
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ MCPJungle is an open source, self-hosted Gateway for all your [Model Context Pro
- [Prompts](#prompts)
- [Tool Groups](#tool-groups)
- [Authentication](#authentication)
- [OAuth 2.0 Integration](#oauth-20-integration)
- [Enterprise features](#enterprise-features-)
- [Access Control](#access-control)
- [OpenTelemetry](#opentelemetry)
Expand Down Expand Up @@ -665,7 +666,41 @@ Or from your configuration file
}
```

Support for Oauth flow is coming soon!
## OAuth 2.0 Integration

MCPJungle supports OAuth 2.0 / OpenID Connect (OIDC) for connecting external LLM platforms like ChatGPT. This uses the Authorization Code flow with PKCE for security.

OAuth is only available in Enterprise mode.

### Setting Up OAuth for ChatGPT

1. Start MCPJungle in enterprise mode and initialize it:
```bash
mcpjungle start --enterprise
mcpjungle init-server
```

2. Create an OAuth client:
```bash
mcpjungle create oauth-client chatgpt \
--redirect-uris "https://chatgpt.com/connector_platform_oauth_redirect"
```

3. Expose MCPJungle publicly (for local development, use [ngrok](https://ngrok.com/)):
```bash
ngrok http 8080
```

4. In ChatGPT, go to **Settings > Connectors > Advanced > Developer mode** and add a connector:
- Endpoint URL: `https://your-ngrok-url/mcp`
- Authentication: **OAuth**
- Client ID: (from step 2)
- Authorize URL: `https://your-ngrok-url/oauth/authorize`
- Token URL: `https://your-ngrok-url/oauth/token`

5. When prompted, authorize access using your MCPJungle username and access token.

MCPJungle exposes OAuth discovery endpoints at `/.well-known/openid-configuration` and `/.well-known/oauth-protected-resource` for automatic client configuration.

## Enterprise Features 🔒

Expand Down Expand Up @@ -818,10 +853,9 @@ Once the mcpjungle server is started, metrics are available at the `/metrics` en
# Current limitations 🚧
We're not perfect yet, but we're working hard to get there!

### 1. MCPJungle does not support OAuth flow for authentication yet
This is a work in progress.
### 1. OAuth refresh token flow

We're collecting more feedback on how people use OAuth with MCP servers, so feel free to start a Discussion or open an issue to share your use case.
While MCPJungle issues refresh tokens during the OAuth flow, the token refresh endpoint is not yet implemented. Users will need to re-authorize when their access tokens expire (after 24 hours). This will be added in a future release.

# Contributing 💻

Expand Down
32 changes: 32 additions & 0 deletions client/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/url"

"github.com/mcpjungle/mcpjungle/internal/model"
"github.com/mcpjungle/mcpjungle/pkg/types"
)

type InitServerResponse struct {
Expand Down Expand Up @@ -50,3 +51,34 @@ func (c *Client) InitServer() (*InitServerResponse, error) {
}
return &initResp, nil
}

// CreateOAuthClient creates a new OAuth client in the registry
func (c *Client) CreateOAuthClient(client *types.CreateOAuthClientRequest) (*model.OAuthClient, error) {
u, err := c.constructAPIEndpoint("/oauth-clients")
if err != nil {
return nil, err
}
body, err := json.Marshal(client)
if err != nil {
return nil, err
}
req, err := c.newRequest(http.MethodPost, u, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusCreated {
return nil, c.parseErrorResponse(resp)
}

var created model.OAuthClient
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
return nil, err
}
return &created, nil
}
48 changes: 48 additions & 0 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ var createToolGroupCmd = &cobra.Command{
RunE: runCreateToolGroup,
}

var createOAuthClientCmd = &cobra.Command{
Use: "oauth-client [name]",
Args: cobra.ExactArgs(1),
Short: "Create an OAuth 2.0 client (Enterprise mode)",
Long: "Create an OAuth 2.0 client for integrating with external LLMs like ChatGPT.",
RunE: runCreateOAuthClient,
}

var (
createMcpClientCmdAllowedServers string
createMcpClientCmdDescription string
Expand All @@ -85,6 +93,9 @@ var (
createUserCmdConfigFilePath string

createToolGroupConfigFilePath string

createOAuthClientCmdRedirectURIs string
createOAuthClientCmdDescription string
)

func init() {
Expand Down Expand Up @@ -142,9 +153,24 @@ func init() {
)
_ = createToolGroupCmd.MarkFlagRequired("conf")

createOAuthClientCmd.Flags().StringVar(
&createOAuthClientCmdRedirectURIs,
"redirect-uris",
"",
"Comma-separated list of allowed redirect URIs",
)
createOAuthClientCmd.Flags().StringVar(
&createOAuthClientCmdDescription,
"description",
"",
"Description of the OAuth client",
)
_ = createOAuthClientCmd.MarkFlagRequired("redirect-uris")

createCmd.AddCommand(createMcpClientCmd)
createCmd.AddCommand(createUserCmd)
createCmd.AddCommand(createToolGroupCmd)
createCmd.AddCommand(createOAuthClientCmd)

rootCmd.AddCommand(createCmd)
}
Expand Down Expand Up @@ -296,6 +322,28 @@ func runCreateToolGroup(cmd *cobra.Command, args []string) error {
return nil
}

func runCreateOAuthClient(cmd *cobra.Command, args []string) error {
req := &types.CreateOAuthClientRequest{
Name: args[0],
RedirectURIs: createOAuthClientCmdRedirectURIs,
Description: createOAuthClientCmdDescription,
}

created, err := apiClient.CreateOAuthClient(req)
if err != nil {
return fmt.Errorf("failed to create OAuth client: %w", err)
}

cmd.Printf("OAuth client '%s' created successfully!\n", created.Name)
cmd.Printf("Client ID: %s\n", created.ClientID)
if created.ClientSecret != "" {
cmd.Printf("Client Secret: %s\n", created.ClientSecret)
}
cmd.Printf("Redirect URIs: %s\n", created.RedirectURIs)

return nil
}

func readToolGroupConfig(filePath string) (*types.ToolGroup, error) {
var input types.ToolGroup

Expand Down
3 changes: 3 additions & 0 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/mcpjungle/mcpjungle/internal/service/config"
"github.com/mcpjungle/mcpjungle/internal/service/mcp"
"github.com/mcpjungle/mcpjungle/internal/service/mcpclient"
"github.com/mcpjungle/mcpjungle/internal/service/oauth"
"github.com/mcpjungle/mcpjungle/internal/service/toolgroup"
"github.com/mcpjungle/mcpjungle/internal/service/user"
"github.com/mcpjungle/mcpjungle/internal/telemetry"
Expand Down Expand Up @@ -425,6 +426,7 @@ func runStartServer(cmd *cobra.Command, args []string) error {

configService := config.NewServerConfigService(dbConn)
userService := user.NewUserService(dbConn)
oauthService := oauth.NewOAuthService(dbConn)

toolGroupService, err := toolgroup.NewToolGroupService(dbConn, mcpService)
if err != nil {
Expand All @@ -437,6 +439,7 @@ func runStartServer(cmd *cobra.Command, args []string) error {
SseMcpProxyServer: sseMcpProxyServer,
MCPService: mcpService,
MCPClientService: mcpClientService,
OAuthService: oauthService,
ConfigService: configService,
UserService: userService,
ToolGroupService: toolGroupService,
Expand Down
34 changes: 28 additions & 6 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,16 +169,38 @@ func (s *Server) checkAuthForMcpProxyAccess() gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing MCP client access token"})
return
}

// First, check if it's a static MCP client token (legacy authentication method)
// Static tokens are created via `mcpjungle create mcp-client` and are long-lived.
client, err := s.mcpClientService.GetClientByToken(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid MCP client token"})
if err == nil {
// inject the authenticated MCP client in context for the proxy to use
ctx = context.WithValue(c.Request.Context(), "client", client)
c.Request = c.Request.WithContext(ctx)
c.Next()
return
}

// inject the authenticated MCP client in context for the proxy to use
ctx = context.WithValue(c.Request.Context(), "client", client)
c.Request = c.Request.WithContext(ctx)
// Second, check if it's an OAuth token issued by mcpjungle (OAuth 2.0 flow)
// OAuth tokens are issued via the /oauth/token endpoint after user authorization.
// They are tied to a specific MCPJungle user and have expiration times.
oauthToken, err := s.oauthService.GetTokenByValue(token)
if err == nil {
// Create a "virtual" McpClient for the OAuth-authenticated user.
// This allows us to reuse the existing proxy authorization logic without major changes.
// The virtual client represents the user's OAuth session.
// TODO: Implement proper User -> Server ACLs instead of wildcard access.
virtualClient := &model.McpClient{
Name: "oauth-user-" + fmt.Sprint(oauthToken.UserID),
AccessToken: oauthToken.Token,
AllowList: []byte("[\"*\"]"), // Wildcard access for authenticated users for now
}
ctx = context.WithValue(c.Request.Context(), "client", virtualClient)
c.Request = c.Request.WithContext(ctx)
c.Next()
return
}

c.Next()
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid access token"})
}
}
Loading