Skip to content

feat: plugin gateway route handlers with auto-namespacing#126

Merged
donbader merged 9 commits into
mainfrom
feat/plugin-routes
Jun 8, 2026
Merged

feat: plugin gateway route handlers with auto-namespacing#126
donbader merged 9 commits into
mainfrom
feat/plugin-routes

Conversation

@dorey-agent

@dorey-agent dorey-agent Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

What

Plugins can now register HTTP route handlers on the gateway, auto-namespaced under /plugins/{plugin-name}/. The mcp-oauth plugin uses this to implement a full OAuth authorization code flow — including dynamic client registration (RFC 7591) for providers like Notion that support it.

Why

Previously, plugins could only contribute middleware (request interceptors). There was no way for a plugin to serve direct HTTP endpoints (needed for OAuth callbacks, webhooks). Users had to manually create token files for OAuth providers — terrible UX.

For MCP providers like Notion that use dynamic registration, users shouldn't need to manually create OAuth apps and copy credentials.

How

Gateway SDK:

  • RegisterRoute(RouteDef) / MatchRoute(path) — route handler registry
  • MiddlewareContext.Abort(status, body) — middleware can short-circuit with a response
  • DiscoverOAuthMetadata() — fetches .well-known/oauth-authorization-server
  • RegisterOAuthClient() — performs RFC 7591 dynamic client registration

Plugin schema:

  • New contributes.gateway.routes[] with path and handler fields
  • Routes auto-prefixed with /plugins/{plugin-name}/ at generate time
  • Conflict detection: duplicate paths fail at generate time

Config:

  • New gateway.public_url field — used for OAuth callbacks

mcp-oauth plugin:

  • Dynamic mode (just mcp_url): auto-discovers OAuth endpoints + registers client
  • Static mode (client_id provided): uses given credentials directly
  • Callback handler at /plugins/mcp-oauth/callback (code exchange + token write)
  • Middleware returns 401 + authorize URL when no token exists
  • HMAC-based CSRF state validation
  • SSRF-safe transport for all external requests

Testing

go test ./internal/... ./core/sdk/...

New tests: route namespacing, conflict detection, template rendering, SDK route registry, middleware abort API, public_url passthrough.

- Add routes contribution type to plugin schema (contributes.gateway.routes)
- Routes are auto-namespaced under /plugins/{plugin-name}/ to prevent conflicts
- Generator validates route path conflicts at generate time
- Add gateway public_url config field for OAuth callbacks
- Extend gateway SDK with RegisterRoute/MatchRoute + middleware Abort API
- Add toJSON template function to middleware/route rendering

mcp-oauth plugin updates:
- Remove redundant token_file option (derive from token_dir + provider name)
- Add OAuth callback handler at /plugins/mcp-oauth/callback
- Middleware returns 401 + authorize_url when no token exists
- Token files now per-provider: {token_dir}/{provider}.json
@dorey-agent

dorey-agent Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Automated Review Summary

Agents: security, correctness, architecture
Findings: 🔴 4 critical | 🟡 6 warning | 💬 1 nit


core/plugins/mcp-oauth/handlers/callback.go:100
🔴 [security] Path traversal via state parameter used as filename. tokenFile := oauthCallbackTokenDir + "/" + state + ".json"state comes directly from the HTTP query string. An attacker can craft ?code=X&state=../../../etc/passwd to write the token JSON to an arbitrary path. Validate that state matches a known provider key before constructing the path, and reject requests where state contains any / or . characters. The oauthCallbackProviders lookup on line ~88 already returns !ok for unknown providers — but the file path is constructed after that check, so it's only safe if the provider keys themselves can never contain path separators. Enforce this explicitly: if strings.ContainsAny(state, "/\\.") { http.Error(...) }

core/plugins/mcp-oauth/handlers/callback.go:79
🔴 [security] No CSRF protection on the OAuth callback. The state parameter is just the provider name (a predictable, static string). Any attacker who can trick a victim's browser into visiting https://gateway.example.com/plugins/mcp-oauth/callback?code=stolen_code&state=notion can inject an arbitrary authorization code into the token exchange. The OAuth spec (RFC 6749 §10.12) requires state to be an unguessable, session-bound nonce. This handler accepts any state that matches a known provider name. Fix: generate a random nonce at authorize-URL-build time, store it (in a short-lived cookie or session), and validate it on callback before proceeding.

core/plugins/mcp-oauth/handlers/callback.go:73
🔴 [correctness] redirect_uri in token exchange is reconstructed from the incoming request, not from public_url. When the gateway sits behind a TLS-terminating reverse proxy (the common deployment), r.TLS == nil so scheme becomes "http", producing a redirect_uri like http://gateway.internal/plugins/mcp-oauth/callback. The OAuth provider will reject this because it won't match the registered https://... callback URL. The public_url config field exists exactly for this — use it: the redirect_uri should be cfg.PublicURL + route.Path. The public_url is written to the gateway runtime config but the callback handler has no access to it (there's no mechanism to pass it through). This is a design gap: add a gateway.PublicURL() SDK accessor, or bake it in via template at generate time alongside {{ .path }}.

core/plugins/mcp-oauth/middlewares/oauth.go:469
🔴 [correctness] buildAuthorizeURL omits redirect_uri. Most OAuth providers require the redirect_uri in the authorization request to match what's provided during token exchange. Without it the provider either uses a registered default (which may not be the gateway callback) or rejects the request. The authorize URL must include redirect_uri: {public_url}/plugins/mcp-oauth/callback. The public_url is available in the gateway runtime config — it needs to be surfaced via the SDK or baked into the middleware template the same way {{ .path }} is baked into the handler.

core/plugins/mcp-oauth/middlewares/oauth.go:43
🟡 [correctness] defaultProvider is selected via non-deterministic map iteration. Go map iteration order is randomized per-process. If a user configures multiple providers, the middleware will randomly pick a different one on each restart, causing unpredictable behavior (wrong token file, wrong authorize URL). The plugin schema allows multiple providers but the middleware is built to serve only one. Either enforce single-provider in the schema (add validation), or store the provider association per domain and look it up from the intercepted request's target host at request time — each mcp_url domain maps to exactly one provider.

core/plugins/mcp-oauth/handlers/callback.go:343
🟡 [security] client_secret is persisted in the token JSON file. The shared volume is mounted into both the gateway and the agent container. Writing client_secret to the token file means any code running in the agent can read it — it's effectively exposed to the full agent runtime. The client_secret is already baked into the generated binary (via template substitution at generate time), so there is no operational reason to persist it. Remove it from writeCallbackToken. Same applies to writeTokenFile in oauth.go if it ever writes the secret.

core/plugins/mcp-oauth/handlers/callback.go:28
🟡 [correctness] init() silently swallows JSON parse failure. If json.Unmarshal fails (e.g. the template produced invalid JSON due to a quoting issue in provider names/values), oauthCallbackProviders is left empty and the handler will return HTTP 400 "unknown provider" for every request — with no log output and no startup error. Add an explicit slog.Error or panic on parse failure so the misconfiguration is visible at startup rather than silently at request time.

internal/generate/v1/generator.go:963
🟡 [correctness] CopyRouteHandlers receives merged options from ALL plugins, not just the contributing plugin's options. collectAllOptions(cfg) returns a flat merge of every plugin's options. If two plugins declare an option with the same key, one will silently overwrite the other in the template context. Middleware handlers use per-plugin option scoping (the template data is built with that plugin's options). Route handlers should do the same: pass each RouteRef its owning plugin's resolved options, not the global merge. This is a correctness issue that will manifest when multiple plugins are installed.

internal/generate/v1/routes.go:101
🟡 [architecture] renderRouteHandler duplicates renderMiddleware with identical logic. Both functions parse Go templates with the same toJSON func map. Extract a shared renderGoTemplate(name, content string, data map[string]any) (string, error) in middleware_copy.go and call it from both. The current duplication means any future change to the template function set (adding a toYAML helper, fixing an escaping bug) must be applied in two places.

core/sdk/gateway/middleware.go:593
🟡 [architecture] AbortStatus, AbortHeaders, and AbortBody are exported fields on MiddlewareContext. Plugin authors can read and mutate these fields directly, bypassing the Abort()/SetAbortHeader() methods. This leaks internal gateway machinery into the public SDK surface. Make the fields unexported (abortStatus, abortHeaders, abortBody) and add corresponding getter methods (IsAborted() bool, AbortStatus() int, etc.) for the gateway runtime to check. The Abort() and SetAbortHeader() methods are the correct write path — don't also expose the fields.

core/plugins/mcp-oauth/handlers/callback.go:20
💬 [correctness] MCP_URL field in oauthProviderConfig is parsed but never used. It's populated from cfg["mcp_url"] in init() but nothing in this file reads p.MCP_URL. Either remove it, or document the intended use (e.g. for a future success-page redirect back to the MCP client). Using a non-idiomatic name (MCP_URL instead of MCPURL or McpURL) also triggers Go lint warnings.

@dorey-agent

dorey-agent Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Automated Review Summary

Agents: security, correctness, edge_cases, architecture
Findings: 🔴 3 critical | 🟡 7 warning | 💬 3 nit


core/plugins/mcp-oauth/handlers/callback.go:96
🔴 [security] Path traversal via state parameter. state comes from the query string and is used directly to construct a filesystem path: oauthCallbackTokenDir + "/" + state + ".json". An attacker that controls the redirect back to this callback (or forges the request) can pass state=../../../etc/cron.d/evil and write arbitrary content to arbitrary paths on the gateway host. Fix: validate state against the known provider names in oauthCallbackProviders (already done two lines up), then use only that validated key — not the raw state string — to construct the filename. Change to tokenFile := oauthCallbackTokenDir + "/" + provider_name_from_map + ".json" using the key returned from the map lookup, not the query param.

core/plugins/mcp-oauth/handlers/callback.go:80
🔴 [security] redirect_uri accepted from an attacker-controlled query parameter. The OAuth spec requires the redirect_uri in the token exchange to be a fixed, pre-registered value — not one read from the incoming request. A malicious party can supply a forged redirect_uri pointing to their own server, submit a valid code, and complete the token exchange against the attacker's endpoint. The gateway.public_url config field exists precisely for this: construct the callback URL as public_url + "/plugins/mcp-oauth/callback" at init time (from template context) and use that constant. Remove the redirect_uri query-param branch entirely.

core/plugins/mcp-oauth/handlers/callback.go:57
🔴 [security] OAuth state parameter provides no CSRF protection. The state value is a predictable, static string (the provider name, e.g. "notion"). Any attacker can craft a URL https://gateway/plugins/mcp-oauth/callback?code=stolen_code&state=notion — there's no nonce to distinguish a legitimate redirect from a forged one. The correct pattern is: generate a cryptographically random nonce at authorize-URL-build time, store it (in a short-lived signed cookie or server-side map keyed by session), and validate it on callback before proceeding. Using the provider name as state is explicitly warned against in RFC 6749 §10.12.

core/plugins/mcp-oauth/middlewares/oauth.go:116
🟡 [security] buildAuthorizeURL omits the redirect_uri parameter. Most OAuth providers (including Notion) require the redirect_uri in the authorization request to exactly match the value in the subsequent token exchange request. Without it, the provider either uses a registered default (fragile and provider-specific) or rejects the request outright. The public_url config field should be threaded through to this function so it can append redirect_uri=<public_url>/plugins/mcp-oauth/callback.

core/plugins/mcp-oauth/handlers/callback.go:87
🟡 [correctness] r.Host used to reconstruct redirect_uri is unreliable behind a reverse proxy. When the gateway sits behind nginx/Caddy/load-balancer, r.TLS == nil will be true (TLS is terminated upstream) and r.Host may be the internal hostname rather than the public one. This produces a redirect_uri that mismatches what the provider has registered, causing token exchange failures. The gateway.public_url config field is the correct source — it should be baked into the handler at generate time via a template variable (same as {{ .path }} is already done).

core/plugins/mcp-oauth/middlewares/oauth.go:429
🟡 [correctness] defaultProvider is selected by non-deterministic map iteration. Go map iteration order is randomized; with two or more providers configured, each gateway restart picks a different defaultProvider. The middleware will inject tokens for whatever provider happens to be selected, silently ignoring all others. This is a silent correctness failure: users with two providers configured will see intermittent 401s or wrong-provider token injection. The middleware needs a proper per-domain → provider mapping instead of a single defaultProvider. As a minimum viable fix, the plugin schema should enforce exactly one provider per middleware instance; if multi-provider support is desired that's a separate issue that should be tracked.

core/plugins/mcp-oauth/middlewares/oauth.go:492
🟡 [edge_cases] Token cache is not invalidated after a successful OAuth callback. oauthState.cachedToken is written at token-read time with a TTL derived from expires_at. When the callback handler writes a fresh token, the middleware's in-memory cache is unaware — it continues returning the cached (absent) entry until the TTL expires. In practice this means: the user completes the OAuth flow, the callback writes the token file, but the very next MCP request still gets a 401 because cachedToken is either nil (first run) or stale. The cache expiry check on line 492 (time.Now().Before(s.cachedUntil)) only helps for refresh cycles, not for the cold-start-after-callback case. Fix: after writing the token file in the callback handler, the cache entry needs to be cleared. Since the two packages don't share state currently, the simplest approach is to set cachedUntil to zero on os.ErrNotExist so the next request always re-reads the file.

core/plugins/mcp-oauth/handlers/callback.go:31
🟡 [edge_cases] Silent swallow of JSON parse failure in init(). if err := json.Unmarshal(...); err == nil { ... } — when the template rendering of {{ toJSON .options.providers }} fails or produces invalid JSON, oauthCallbackProviders stays empty and every callback request returns "unknown provider". There is no log line, no startup panic, no indication anything is wrong. This should be if err != nil { panic(...) } or at minimum slog.Error(...), since an empty provider map renders the entire callback handler non-functional and the failure is 100% a configuration/code-generation error, not a runtime condition.

core/plugins/mcp-oauth/handlers/callback.go:252
🟡 [security] Token exchange error response leaks internal details to the client. http.Error(w, fmt.Sprintf("token exchange failed: %v", err), http.StatusInternalServerError)err can contain the token endpoint URL (including any embedded credentials), the raw HTTP error body from the OAuth provider (which may include internal provider error codes or stack traces), and the provider name. These should be logged server-side and a generic "internal error" returned to the client.

internal/generate/v1/routes.go:46
🟡 [edge_cases] collectPluginRoutes iterates the resolved map non-deterministically. Route order in gwCfg.Routes varies across runs because Go map iteration is random. This means generated handler filenames and their order in the gateway build directory differ between runs, breaking reproducibility and build caching (any caching layer keying on output file contents/order will see spurious misses). Fix: collect the plugin refs into a sorted slice before iterating.

internal/generate/v1/routes.go:108
💬 [architecture] toJSON template function is duplicated across renderRouteHandler (routes.go) and renderMiddleware (middleware_copy.go). The two implementations are byte-for-byte identical. Extract it to a shared templateFuncMap() helper in the v1 package and reference it from both render functions. This is the same abstraction that already exists for sanitizeFilename.

core/sdk/gateway/middleware.go:593
💬 [architecture] MiddlewareContext abort fields are exported struct fields, not encapsulated behind the Abort/SetAbortHeader methods. Plugin code can bypass the methods and set AbortStatus, AbortHeaders, AbortBody directly, making the invariants (e.g. AbortHeaders is nil-safe inside SetAbortHeader) easy to violate. The gateway runtime also reads these fields directly. If these are intended to be read by the gateway runtime, document them with a // For gateway runtime use only comment. If they're intended to be set only by plugin code, make them unexported and expose a getter (e.g. IsAborted() bool, AbortResponse() (int, http.Header, string)).

core/plugins/mcp-oauth/handlers/callback.go:268
💬 [security] Success page renders the raw state query param as HTML without escaping. fmt.Fprintf(w, "...<strong>%s</strong>...", state)state is user-controlled. While it's validated against the known provider map earlier, using %s in HTML context still allows any stored HTML-injection if a provider name ever contains <, >, or &. Use html.EscapeString(state) or template/html for any user-supplied value in HTML responses.

@dorey-agent dorey-agent Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Review Summary

Agents: unknown
Findings: 🔴 0 critical | 🟡 0 warning | 💬 0 nit

}

func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[unknown] [security] CSRF: state used as provider name instead of a nonce

The state parameter is validated only by checking whether it matches a known provider name. It carries no CSRF protection. RFC 6749 §10.12 requires state to be an unguessable random value tied to the session that initiated the authorization request. As-is, any party that obtains a valid authorization code (e.g., via referrer leakage, log exposure, or interception) can craft:

GET /plugins/mcp-oauth/callback?code=STOLEN&state=notion

and the handler will exchange the code and write the resulting token without any verification that this callback corresponds to a flow this gateway started.

Fix: generate a random nonce on the authorize redirect, store it (e.g., in a short-lived file or in-memory map keyed by nonce), and verify on callback. Encode both nonce and provider name in state (e.g., notion:hex-nonce).

if v, ok := cfg["client_id"].(string); ok {
p.ClientID = v
}
if v, ok := cfg["scopes"].(string); ok {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[unknown] [correctness] Non-deterministic provider selection

Go map iteration order is randomized at runtime. This loop:

for name := range providers {
    defaultProvider = name
    break
}

picks an arbitrary provider when multiple are configured. The middleware then only handles that one provider — silently ignoring all others. Given that the plugin schema explicitly supports a providers map (multi-provider), this is a correctness gap: a user who configures notion and github will have one of them work and the other silently fail on every process restart, non-deterministically.

For the current single-provider-per-middleware use case, either:

  1. Validate at generate time that exactly one provider is configured per installation, and error clearly if not, or
  2. Support multiple providers by matching on request domain → provider mapping.

// Exchange authorization code for token
redirectURI := r.URL.Query().Get("redirect_uri")
if redirectURI == "" {
// Reconstruct from request

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[unknown] [correctness] redirect_uri reconstructed from r.TLS — always wrong behind a proxy

When the gateway runs behind a TLS-terminating load balancer or reverse proxy (the normal production setup), r.TLS is always nil even for HTTPS requests. This produces http://... redirect URIs sent to the OAuth token endpoint, which will:

  1. Not match the https:// URI registered with the provider → token exchange fails, or
  2. Accidentally expose the code exchange over HTTP if the provider blindly accepts it.

gateway.public_url was added in this very PR for exactly this reason. It should be used here:

redirectURI = gatewayPublicURL + r.URL.Path  // baked in via {{ .gateway.public_url }}

The query-param redirect_uri override path is also suspicious — the OAuth provider never sends redirect_uri back in the callback; this dead branch should be removed.

@@ -73,6 +116,18 @@ func init() {
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[unknown] [correctness] buildAuthorizeURL omits redirect_uri

redirect_uri is missing from the constructed authorize URL. Many providers (GitHub, Google, Microsoft) require it and will reject the request or use an ambiguous default. More importantly, without an explicit redirect_uri in the authorize request, the token endpoint can't verify the redirect matches — weakening the auth code binding.

The public_url from config should be used to construct the full callback URL:

params.Set("redirect_uri", publicURL+"/plugins/mcp-oauth/callback")

This is doubly important given the CSRF issue above — redirect_uri binding is the other defense that limits where auth codes can be redirected.

oauthCallbackTokenDir = "{{ .options.token_dir }}"
providersJSON := `{{ toJSON .options.providers }}`
var providers map[string]map[string]any
if err := json.Unmarshal([]byte(providersJSON), &providers); err == nil {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[unknown] [edge case] Silent JSON parse failure leaves provider map empty

if err := json.Unmarshal([]byte(providersJSON), &providers); err == nil {
    // populate oauthCallbackProviders
}

If the template renders invalid JSON (e.g., a provider option contains backtick characters, or toJSON fails silently), json.Unmarshal errors and oauthCallbackProviders stays empty. The init() still runs, gateway.RegisterRoute registers the handler, but every callback returns 400 unknown provider with no diagnostic about why the config failed to parse.

This same pattern exists in middlewares/oauth.go. Both should log a fatal/panic or return an early error on parse failure, since there is no valid runtime state if provider config is unreadable.

}

data, err := json.MarshalIndent(stored, "", " ")
if err != nil {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[unknown] [security] client_secret written to the shared token file

if provider.ClientSecret != "" {
    stored["client_secret"] = provider.ClientSecret
}

The OAuth client_secret is written into the token JSON file on the shared oauth-tokens volume, which is mounted into both the gateway and agent containers. The secret is already available at runtime via the baked-in template; there's no functional reason to persist it in the token file. The middleware reads client_secret from the storedToken struct for refresh, but it could read from the in-memory config instead.

Storing secrets in token files expands the blast radius: a compromised agent container, an accidental log dump of the token file, or volume backup exposure all leak the secret alongside the token.

Comment thread core/plugins/mcp-oauth/plugin.yaml Outdated
type: object
required: true
description: "Map of provider name to MCP config"
description: "Map of provider name to MCP config (each needs mcp_url, client_id, client_secret)"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[unknown] [regression] Removing token_file is a breaking change without a migration path

The token_file option is removed and the token path is now derived as {token_dir}/{defaultProvider}.json. Existing deployments that:

  1. Explicitly set token_file, or
  2. Have a token stored at the old default /data/oauth-tokens/token.json

will silently fail after upgrading — the middleware will treat the missing file as "not authenticated" and start returning 401s, triggering the OAuth flow from scratch.

At minimum, the changelog and plugin README should document this migration requirement. Ideally, generate-time validation should error if an unknown option (including the removed token_file) is provided, rather than silently ignoring it.

b, err := json.Marshal(v)
if err != nil {
return "", fmt.Errorf("toJSON: %w", err)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[unknown] [architecture] toJSON template function duplicated

The toJSON function is defined identically in both middleware_copy.go:renderMiddleware and routes.go:renderRouteHandler. Extract it to a shared templateFuncMap() helper in this package to keep them in sync. Otherwise a future change (e.g., adding HTML escaping) needs to be made in two places and one will inevitably be missed.

dorey-agent[bot] added 8 commits June 8, 2026 08:50
- CSRF: add nonce-based state parameter (single-use, validated on callback)
- Path traversal: state is validated against known provider map (no raw file path construction)
- redirect_uri: derived from gateway public_url (baked at generate time), never from request headers
- redirect_uri included in authorize URL for proper OAuth round-trip
- Non-deterministic provider: use sorted keys for deterministic default selection
- client_secret: no longer persisted to token files (stays in gateway binary only)
- Error logging: JSON parse failures now logged instead of silently swallowed
- Error messages: internal details (endpoint URLs, provider errors) no longer leaked to HTTP responses
- HTML escaping: provider name escaped in success page
- public_url passed to route handler templates via new parameter
- Remove cross-package function calls (GenerateOAuthNonce, OAuthCallbackURL)
  between handlers/ and middlewares/ — they compile as separate packages in CI
- Use deterministic HMAC-based CSRF state derived from providers config
  (both packages derive the same key independently)
- Remove ClientSecret field from storedToken struct (no longer persisted)
- Remove unused crypto/rand import
- Gateway SDK: DiscoverOAuthMetadata() + RegisterOAuthClient() utilities
- Middleware auto-detects mode: client_id present → static, absent → dynamic
- Dynamic mode: discovers .well-known/oauth-authorization-server, registers client
- Registration response cached to {token_dir}/{provider}.reg.json for reuse
- Updated README with dual-mode examples (dynamic for Notion, static for custom)
- For Notion/MCP providers: just provide mcp_url, everything else auto-discovered
- Gateway main.go: route handler dispatch on health server (port 8080)
- Compose: auto-expose gateway port 8080 when plugin routes are registered
- Default public_url to http://localhost:8080 when not configured
- local-coding example: add Notion MCP with mcp-oauth plugin (dynamic mode)
- README: document Notion OAuth setup flow (zero-config, just mcp_url)
Plugin now generates gateway service entries from providers.mcp_url
automatically — users don't need to duplicate the URL in gateway.services.

Uses Go template range over providers map to emit service entries at
generate time.
Each provider gets its own middleware scoped to its mcp_url domain.
Request to mcp.notion.com → notion's authorize URL.
Request to mcp.datadog.com → datadog's authorize URL.

Previously used a single middleware with a hardcoded default provider,
which returned the wrong authorize URL for multi-provider configs.
@donbader donbader merged commit 2296b3d into main Jun 8, 2026
6 of 7 checks passed
@donbader donbader deleted the feat/plugin-routes branch June 8, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant