Skip to content

Commit d0ff46b

Browse files
committed
feat(mcp): support custom OAuth callbackRedirectURL for remote toolsets
Add an optional `callbackRedirectURL` field to the remote MCP OAuth config. When set, it is advertised to the authorization server as the OAuth `redirect_uri` instead of the default `http://127.0.0.1:{callbackPort}/callback`. The literal placeholder `${callbackPort}` is substituted with the actual port the local callback server is listening on. This lets users put a public-facing proxy (HTTPS or pre-registered static redirect) in front of the local loopback callback, working around auth servers that refuse http://localhost redirect URIs. The local callback server still listens on 127.0.0.1:{callbackPort}; only the advertised redirect URI changes. Validation: - URL must be absolute (scheme + host) once ${callbackPort} is substituted. - Scheme must be http or https; other schemes (javascript:, file:, ftp:, …) are rejected. - http is only allowed on loopback hosts (127.0.0.1, ::1, localhost); non-loopback http would expose the authorization code on the wire (RFC 8252 §7.3). Includes JSON schema + docs update, a runnable example, and unit tests for validation and the pure buildRedirectURI substitution helper. Assisted-By: docker-agent
1 parent 77abf88 commit d0ff46b

9 files changed

Lines changed: 448 additions & 1 deletion

File tree

agent-schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1803,6 +1803,10 @@
18031803
"items": {
18041804
"type": "string"
18051805
}
1806+
},
1807+
"callbackRedirectURL": {
1808+
"type": "string",
1809+
"description": "Optional OAuth redirect URI used in place of http://127.0.0.1:{callbackPort}/callback. The literal placeholder ${callbackPort} is replaced with the actual local callback port at runtime. The external URL is expected to redirect the browser back to the local callback server."
18061810
}
18071811
},
18081812
"required": [

docs/features/remote-mcp/index.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,40 @@ toolsets:
6868
| `clientSecret` | string | ✗ | OAuth client secret. Omit for public clients using PKCE. |
6969
| `callbackPort` | integer | ✗ | Local port to receive the OAuth redirect. If omitted, docker-agent picks a random free port. |
7070
| `scopes` | array[string] | ✗ | Scopes to request during the authorization step. Values are server-specific. |
71+
| `callbackRedirectURL` | string | ✗ | Custom OAuth redirect URI. Useful when the auth server requires HTTPS or a pre-registered URL. The literal placeholder `${callbackPort}` is replaced with the actual local callback port. See below. |
7172

7273
Secrets should be stored in a credential helper or environment variable rather than committed — see [Secrets]({{ '/guides/secrets/' | relative_url }}) for interpolation patterns.
7374

75+
### Custom redirect URI (`callbackRedirectURL`)
76+
77+
Some authorization servers require the OAuth `redirect_uri` to be HTTPS or to match a URL that was pre-registered during app creation — neither of which plays nicely with a locally-bound loopback address such as `http://127.0.0.1:8765/callback`.
78+
79+
To work around this, set `callbackRedirectURL` to a public URL that redirects back to the local callback server. The literal placeholder `${callbackPort}` is substituted with the actual port the local callback server is listening on (either `callbackPort` when set, or the randomly-assigned port otherwise).
80+
81+
```yaml
82+
toolsets:
83+
- type: mcp
84+
remote:
85+
url: "https://mcp.example.com/mcp"
86+
transport_type: "streamable"
87+
oauth:
88+
clientId: "my-app-client-id"
89+
callbackPort: 8765
90+
# Advertise this URL to the authorization server. The external
91+
# service at redirect.example.com is expected to 302-redirect the
92+
# browser to http://127.0.0.1:8765/callback preserving the query
93+
# string (code, state, …).
94+
callbackRedirectURL: "https://redirect.example.com/cb?port=${callbackPort}"
95+
```
96+
97+
The local callback server still listens on the loopback interface on `callbackPort`; only the `redirect_uri` advertised to the authorization server changes.
98+
99+
**Validation rules:**
100+
101+
- The URL must be absolute (scheme + host) once `${callbackPort}` has been substituted.
102+
- Only `http` and `https` schemes are accepted.
103+
- `http` is only allowed when the host is a loopback address (`127.0.0.1`, `::1`, `localhost`); any other host must use `https` to avoid exposing the authorization `code` on the wire (RFC 8252 §7.3).
104+
74105
## Project Management & Collaboration
75106

76107
| Service | URL | Transport | Description |
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env docker agent run
2+
3+
# Example: Remote MCP server with a custom OAuth callback redirect URL.
4+
#
5+
# Some authorization servers refuse http://localhost or http://127.0.0.1
6+
# redirect URIs (they require HTTPS or a pre-registered URL). Use
7+
# `callbackRedirectURL` to advertise a public URL to the authorization
8+
# server while still receiving the final callback on the local loopback
9+
# interface.
10+
#
11+
# The literal placeholder ${callbackPort} is replaced at runtime with the
12+
# actual port the local callback server is listening on (either
13+
# `callbackPort` when set, or a random free port otherwise).
14+
#
15+
# The external service at redirect.example.com is expected to 302-redirect
16+
# the browser back to http://127.0.0.1:${callbackPort}/callback, preserving
17+
# the OAuth query parameters (code, state, ...).
18+
19+
agents:
20+
root:
21+
model: openai/gpt-4.1-mini
22+
description: Assistant with a remote MCP tool using a custom OAuth redirect URL
23+
instruction: You are a helpful assistant with access to remote tools.
24+
toolsets:
25+
- type: mcp
26+
remote:
27+
url: "https://mcp.example.com/mcp"
28+
transport_type: streamable
29+
oauth:
30+
clientId: "your-client-id"
31+
clientSecret: "your-client-secret"
32+
callbackPort: 8765
33+
callbackRedirectURL: "https://redirect.example.com/cb?port=${callbackPort}"
34+
scopes:
35+
- "read"
36+
- "write"

pkg/config/latest/types.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,20 @@ type RemoteOAuthConfig struct {
831831
ClientSecret string `json:"clientSecret,omitempty"`
832832
CallbackPort int `json:"callbackPort,omitempty"`
833833
Scopes []string `json:"scopes,omitempty"`
834+
// CallbackRedirectURL, when set, is used as the OAuth redirect URI
835+
// instead of the default http://127.0.0.1:{callbackPort}/callback.
836+
// This allows inserting a public-facing proxy (e.g. a URL shortener or
837+
// a pre-registered static redirect) in front of the local callback
838+
// server — useful for authorization servers that require the redirect
839+
// URI to be HTTPS or pre-registered.
840+
//
841+
// The literal placeholder ${callbackPort} is replaced with the actual
842+
// port the local callback server is listening on (either CallbackPort
843+
// when set, or a random free port otherwise). The external URL is
844+
// expected to redirect the browser back to
845+
// http://127.0.0.1:{callbackPort}/callback preserving the OAuth query
846+
// parameters.
847+
CallbackRedirectURL string `json:"callbackRedirectURL,omitempty"`
834848
}
835849

836850
// DeferConfig represents the deferred loading configuration for a toolset.

pkg/config/latest/validate.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ package latest
22

33
import (
44
"errors"
5+
"fmt"
6+
"net"
7+
"net/url"
8+
"strings"
59
)
610

711
func (t *Config) UnmarshalYAML(unmarshal func(any) error) error {
@@ -156,6 +160,11 @@ func (t *Toolset) validate() error {
156160
if t.Remote.OAuth.CallbackPort != 0 && (t.Remote.OAuth.CallbackPort < 1 || t.Remote.OAuth.CallbackPort > 65535) {
157161
return errors.New("oauth callbackPort must be between 1 and 65535")
158162
}
163+
if t.Remote.OAuth.CallbackRedirectURL != "" {
164+
if err := validateCallbackRedirectURL(t.Remote.OAuth.CallbackRedirectURL); err != nil {
165+
return err
166+
}
167+
}
159168
}
160169
case "a2a":
161170
if t.URL == "" {
@@ -184,3 +193,51 @@ func (t *Toolset) validate() error {
184193

185194
return nil
186195
}
196+
197+
// isLoopbackHost reports whether host is a loopback address (with or without
198+
// a port component). It accepts IPv4 loopback, IPv6 loopback, and the literal
199+
// "localhost".
200+
func isLoopbackHost(hostPort string) bool {
201+
host := hostPort
202+
if h, _, err := net.SplitHostPort(hostPort); err == nil {
203+
host = h
204+
}
205+
host = strings.Trim(host, "[]") // strip IPv6 brackets
206+
if strings.EqualFold(host, "localhost") {
207+
return true
208+
}
209+
if ip := net.ParseIP(host); ip != nil {
210+
return ip.IsLoopback()
211+
}
212+
return false
213+
}
214+
215+
// validateCallbackRedirectURL ensures raw is a well-formed absolute URL
216+
// suitable for use as an OAuth redirect_uri.
217+
//
218+
// Rules:
219+
// - Must parse as an absolute URL (scheme + host) once the ${callbackPort}
220+
// placeholder has been substituted with a dummy value.
221+
// - Scheme must be http or https. Other schemes (javascript:, file:, ftp:,
222+
// …) are rejected: the browser will be navigated to this URL by the
223+
// authorization server.
224+
// - http is only permitted for loopback hosts (RFC 8252 §7.3); any other
225+
// host must use https, since non-loopback http redirect URIs allow the
226+
// authorization code to be exposed on the wire.
227+
func validateCallbackRedirectURL(raw string) error {
228+
// Substitute the placeholder with a dummy port so url.Parse accepts the
229+
// string (Go's parser validates that ports are numeric).
230+
probe := strings.ReplaceAll(raw, "${callbackPort}", "1")
231+
u, err := url.Parse(probe)
232+
if err != nil || u.Scheme == "" || u.Host == "" {
233+
return fmt.Errorf("oauth callbackRedirectURL must be an absolute URL: %q", raw)
234+
}
235+
scheme := strings.ToLower(u.Scheme)
236+
if scheme != "http" && scheme != "https" {
237+
return fmt.Errorf("oauth callbackRedirectURL scheme must be http or https, got %q", u.Scheme)
238+
}
239+
if scheme == "http" && !isLoopbackHost(u.Host) {
240+
return fmt.Errorf("oauth callbackRedirectURL must use https for non-loopback hosts: %q", raw)
241+
}
242+
return nil
243+
}

pkg/config/latest/validate_test.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,166 @@ agents:
216216
})
217217
}
218218
}
219+
220+
func TestToolset_Validate_MCP_RemoteOAuth_CallbackRedirectURL(t *testing.T) {
221+
t.Parallel()
222+
223+
tests := []struct {
224+
name string
225+
config string
226+
wantErr string
227+
}{
228+
{
229+
name: "callbackRedirectURL absolute URL is accepted",
230+
config: `
231+
version: "8"
232+
agents:
233+
root:
234+
model: "openai/gpt-4"
235+
toolsets:
236+
- type: mcp
237+
remote:
238+
url: https://mcp.example.com/sse
239+
oauth:
240+
clientId: cid
241+
callbackRedirectURL: https://redirect.example.com/cb
242+
`,
243+
wantErr: "",
244+
},
245+
{
246+
name: "callbackRedirectURL with placeholder is accepted",
247+
config: `
248+
version: "8"
249+
agents:
250+
root:
251+
model: "openai/gpt-4"
252+
toolsets:
253+
- type: mcp
254+
remote:
255+
url: https://mcp.example.com/sse
256+
oauth:
257+
clientId: cid
258+
callbackRedirectURL: "https://redirect.example.com/cb?port=${callbackPort}"
259+
`,
260+
wantErr: "",
261+
},
262+
{
263+
name: "http on loopback is accepted",
264+
config: `
265+
version: "8"
266+
agents:
267+
root:
268+
model: "openai/gpt-4"
269+
toolsets:
270+
- type: mcp
271+
remote:
272+
url: https://mcp.example.com/sse
273+
oauth:
274+
clientId: cid
275+
callbackRedirectURL: "http://localhost:${callbackPort}/cb"
276+
`,
277+
wantErr: "",
278+
},
279+
{
280+
name: "http on non-loopback host is rejected",
281+
config: `
282+
version: "8"
283+
agents:
284+
root:
285+
model: "openai/gpt-4"
286+
toolsets:
287+
- type: mcp
288+
remote:
289+
url: https://mcp.example.com/sse
290+
oauth:
291+
clientId: cid
292+
callbackRedirectURL: "http://redirect.example.com/cb"
293+
`,
294+
wantErr: "must use https for non-loopback hosts",
295+
},
296+
{
297+
name: "javascript scheme is rejected",
298+
config: `
299+
version: "8"
300+
agents:
301+
root:
302+
model: "openai/gpt-4"
303+
toolsets:
304+
- type: mcp
305+
remote:
306+
url: https://mcp.example.com/sse
307+
oauth:
308+
clientId: cid
309+
callbackRedirectURL: "javascript:alert(1)"
310+
`,
311+
wantErr: "must be an absolute URL",
312+
},
313+
{
314+
name: "ftp scheme is rejected",
315+
config: `
316+
version: "8"
317+
agents:
318+
root:
319+
model: "openai/gpt-4"
320+
toolsets:
321+
- type: mcp
322+
remote:
323+
url: https://mcp.example.com/sse
324+
oauth:
325+
clientId: cid
326+
callbackRedirectURL: "ftp://example.com/cb"
327+
`,
328+
wantErr: "scheme must be http or https",
329+
},
330+
{
331+
name: "relative callbackRedirectURL is rejected",
332+
config: `
333+
version: "8"
334+
agents:
335+
root:
336+
model: "openai/gpt-4"
337+
toolsets:
338+
- type: mcp
339+
remote:
340+
url: https://mcp.example.com/sse
341+
oauth:
342+
clientId: cid
343+
callbackRedirectURL: /just/a/path
344+
`,
345+
wantErr: "oauth callbackRedirectURL must be an absolute URL",
346+
},
347+
{
348+
name: "garbage callbackRedirectURL is rejected",
349+
config: `
350+
version: "8"
351+
agents:
352+
root:
353+
model: "openai/gpt-4"
354+
toolsets:
355+
- type: mcp
356+
remote:
357+
url: https://mcp.example.com/sse
358+
oauth:
359+
clientId: cid
360+
callbackRedirectURL: "://bad-url"
361+
`,
362+
wantErr: "oauth callbackRedirectURL must be an absolute URL",
363+
},
364+
}
365+
366+
for _, tt := range tests {
367+
t.Run(tt.name, func(t *testing.T) {
368+
t.Parallel()
369+
370+
var cfg Config
371+
err := yaml.Unmarshal([]byte(tt.config), &cfg)
372+
373+
if tt.wantErr != "" {
374+
require.Error(t, err)
375+
require.Contains(t, err.Error(), tt.wantErr)
376+
} else {
377+
require.NoError(t, err)
378+
}
379+
})
380+
}
381+
}

pkg/tools/mcp/oauth.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,15 @@ func resourceMetadataFromWWWAuth(wwwAuth string) string {
165165
return ""
166166
}
167167

168+
// callbackRedirectURLFrom is a nil-safe accessor for the optional
169+
// CallbackRedirectURL field on a RemoteOAuthConfig.
170+
func callbackRedirectURLFrom(c *latest.RemoteOAuthConfig) string {
171+
if c == nil {
172+
return ""
173+
}
174+
return c.CallbackRedirectURL
175+
}
176+
168177
// oauthTransport wraps an HTTP transport with OAuth support
169178
type oauthTransport struct {
170179
base http.RoundTripper
@@ -355,7 +364,7 @@ func (t *oauthTransport) handleManagedOAuthFlow(ctx context.Context, authServer,
355364
return fmt.Errorf("failed to start callback server: %w", err)
356365
}
357366

358-
redirectURI := callbackServer.GetRedirectURI()
367+
redirectURI := callbackServer.resolveRedirectURI(callbackRedirectURLFrom(t.oauthConfig))
359368
slog.Debug("Using redirect URI", "uri", redirectURI)
360369

361370
var clientID string

0 commit comments

Comments
 (0)