Skip to content

Add streamable HTTP MCP handler with app auth config#62975

Draft
gabrielcorado wants to merge 14 commits intomasterfrom
gabrielcorado/push-ozmlomzwoyml
Draft

Add streamable HTTP MCP handler with app auth config#62975
gabrielcorado wants to merge 14 commits intomasterfrom
gabrielcorado/push-ozmlomzwoyml

Conversation

@gabrielcorado
Copy link
Copy Markdown
Contributor

@gabrielcorado gabrielcorado commented Jan 20, 2026

Related to RFD 0030e.

Adds the web API endpoints that will accept/handle streamable HTTP MCP connections with app authentication (managed/done by appauthconfig service, introduced at #62324).


Manual testing

  • App access
    • Cookie credentials (launch through WebUI)
    • Certificates session (tsh app login)
    • HA scenario (the app servers go down and the session is renewed).
  • MCP access
    • Streamable with local proxy (tsh mcp connect).
    • STDIO (tsh mcp connect).
    • Streamable with app auth config JWT (more details on setup below)
    • HA scenario (the app servers go down and the session is renewed).
Setup

To test the app auth config JWT, we need a JWT token issuer with JSON Web Key Set (JWKS). For this, I used Teleport itself.

First, start your Streamble MCP server. We're using mcp-everything:

$ bunx @modelcontextprotocol/server-everything streamableHttp
Starting Streamable HTTP server...
MCP Streamable HTTP Server listening on port 3001

Second, configure the app_service:

app_service:
  enabled: true
  debug_app: true  # Important, as this is how we'll grab the user's JWT token.
  apps:
  - name: mcp-everything
    uri: mcp+http://localhost:3001/mcp

With this running, we can grab the dumper address and JWT that we'll use for accessing the MCP:

$ tsh app login dumper
...
$ curl \
  --cert ".tsh-home/keys/proxy.teleport.dev/alice-app/root.teleport.dev/dumper.crt" \
  --key ".tsh-home/keys/proxy.teleport.dev/alice-app/root.teleport.dev/dumper.key" \
  https://dumper.proxy.teleport.dev:443 -k

GET / HTTP/1.1
Host: 127.0.0.1:64703
Accept: */*
Accept-Encoding: gzip
Teleport-Jwt-Assertion: xxxxxxx
User-Agent: curl/8.7.1
X-Forwarded-For: 127.0.0.1
X-Forwarded-Host: dumper.proxy.teleport.dev:4443
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Ssl: on
X-Real-Ip: 127.0.0.1

Now, set up the app auth config:

version: v1
kind: app_auth_config
metadata:
  name: example
spec:
  app_labels:
  - name: teleport.internal/app-sub-kind
    values: [mcp]
  jwt:
    issuer: cluster-name
    audience: 127.0.0.1:64703 # This is the dumper Host.
    jwks_url: https://issuer/.well-known/jwks.json

With everything setup you can now use the MCP inspector to connect to the server.

  • Use https://your-proxy-addr/mcp/apps/mcp-everything address.
  • Set Authorization header with Bearer xxx value using the JWT response from the dumper request.

@gabrielcorado gabrielcorado added the no-changelog Indicates that a PR does not require a changelog entry label Jan 20, 2026
@github-actions github-actions bot requested review from Joerger and Tener January 20, 2026 01:37
Copy link
Copy Markdown
Contributor

@greedy52 greedy52 left a comment

Choose a reason for hiding this comment

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

🎉 first round

Comment thread lib/web/apiserver.go Outdated
Comment thread lib/web/app/handler.go
Comment thread lib/web/app/handler.go Outdated
Comment thread lib/web/app/match.go Outdated
Comment thread lib/web/app/handler.go
Comment thread lib/web/app/match.go Outdated
Comment thread lib/web/app/match.go Outdated
Comment thread lib/web/app/match_test.go Outdated
@tele-lion
Copy link
Copy Markdown
Contributor

managed/done by appauthconfig service, introduced at #62824

Is the ref'ed PR really the one about app auth config? I'm asking because I'm the author of that PR and I'm a bit confused 🙂

@gabrielcorado
Copy link
Copy Markdown
Contributor Author

@tele-lion The correct PR is #62324. Sorry about that, GitHub auto-completed to a different PR I've updated the PR description.

Comment thread lib/web/app/handler.go
Comment thread lib/web/app/handler_test.go
Comment thread lib/web/app/handler_test.go Outdated
Comment thread lib/web/app/handler_test.go Outdated
Comment thread lib/web/app/match.go Outdated
Comment thread integration/appaccess/fixtures.go Outdated
Comment thread lib/web/app/handler.go
Comment thread lib/web/app/handler.go
Comment on lines +622 to +631
for config, err := range clientutils.Resources(ctx, h.c.AccessPoint.ListAppAuthConfigs) {
if err != nil {
return nil, trace.Wrap(err, "unable to retrieve app auth configs")
}
convertedLabels := make(types.Labels)
for k, vs := range label.ToMap(config.Spec.AppLabels) {
convertedLabels[k] = apiutils.Strings(vs)
}

matched, message, err := services.MatchLabelGetter(convertedLabels, app)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Have we considered any alternative APIs to retrieve an AppAuthConfig matching a specific App? It seems suboptimal to have to walk all AppAuthConfigs and match each one by labels just to find the one we are looking for.

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.

Do you mean an alternative to label matching, or a more efficient way to perform it?

Initially, I've placed a short TTL cache on this as well, so that if it receives multiple subsequent requests in a short period, they all use the same app auth config.

I've removed it because I encountered issues during testing when changing the app auth config while requests were ongoing (such as with active MCP sessions). In this situation, it could cause disruptions (for example, if the JWT audience changed or the app auth config became invalid). However, if we see this as valuable, we can consider a better cache expiration strategy.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No I didn't mean caching the results here. I was more curious of ListAppAuthConfigs followed by label matching was the best way to find a single app that matched app auth configs.

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.

The original idea is to limit app auth config to a small subset of your apps; that's why we're testing the requested app to see if it can use any app auth config. Worth noting that on the original use-case, the clusters should have a small set of app auth configs configured (usually one per SSO provider on the cluster).

After speaking with Steve, we came up with a solution that would avoid doing this matching (and app auth config listing) at every request and leave it only when starting a new session. Currently, we need to load the config (and perform the app matching) to retrieve the header name configuration (so we can get the JWT token). If we drop this option and always use the default value (Authorization), we don't need to load the configs. The cost of this change would be making the feature less flexible.

If we think this might become a bottleneck, implementing this change should be simple. Let me know your thoughts.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The original idea is to limit app auth config to a small subset of your apps

Can we assume this will always hold? Should we design for a world where this is no longer the case now?

we came up with a solution that would avoid doing this matching (and app auth config listing) at every request and leave it only when starting a new session

Loading a single time would be better than on every request.

Copy link
Copy Markdown
Contributor

@greedy52 greedy52 Mar 2, 2026

Choose a reason for hiding this comment

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

Each app_auth_config is basically a new SSO connector but just for apps instead of users. I wouldn't imagine there is a world with many of them. That said, we do handle this well for SSO connectors by having a default connector in auth config or for user to explicitly select a connector name.

We could replicate the part for caller to provide a connector name here. That will require significant redesign and will not have the best UX as we have to embed extra info into the token.

What Gabriel and I have discussed is to drop the custom auth header so we always target Authorization. This way we can compute the session ID without the need to grab app_auth_config for existing sessions (so we only grab app_auth_config for new sessions). I like this because it also reduces number of knobs.

An alternative is to use a watcher similar to what Tyler did for database servers where the watcher serves as a cache we can easily loop through:
#63878

@rosstimothy what do you think about the watcher approach?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What Gabriel and I have discussed is to drop the custom auth header so we always target Authorization. This way we can compute the session ID without the need to grab app_auth_config for existing sessions (so we only grab app_auth_config for new sessions). I like this because it also reduces number of knobs.

This sounds reasonable to me. Is there any reason we didn't pursue this to begin with? What are the downsides to this approach?

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.

We wanted to provide a custom solution that allows them to use their own set of header names to forward credentials (JWT tokens). Those are not a requirement but play well when you're dealing with multiple tokens, for example, when your app receives an Authorization header, after doing a token exchange, you could use a different naming to avoid confusion, like Teleport-Authorization-JWT. But it turned out those are not hard requirements and we can drop this customization,

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.

I've updated the implementation to avoid fetching and matching the app auth config for every request. I'll drop the authorization header property in a separate PR as it will require Terraform and other docs changes.

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.

PR removing the option from config and service: #64350

Comment thread lib/web/app/handler.go Outdated
func (h *Handler) getSessionWithAppAuth(ctx context.Context, ws types.WebSession, appAuthConfig *appauthconfigv1.AppAuthConfig) (*sessionWithAppAuth, error) {
// Put the session in the cache so the next request can use it.
ttl := ws.Expiry().Sub(h.c.Clock.Now())
sess, err := utils.FnCacheGetWithTTL(ctx, h.cache, ws.GetName(), ttl, func(ctx context.Context) (*sessionWithAppAuth, error) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there any chance that this key conflicts with a traditional session? Should we prefix the key to better differentiate?

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.

You brought an important point. Since we're using the WebSession name, there shouldn't be any session conflicts, and if there is, FnCacheGetWithTTL would return an error due to a type mismatch (which would prevent using the wrong session type).

However, since the app auth config sessions use the CreateAppSessionForAppAuth to create sessions, we have two ways of defining the WebSession name (ID):

There is a chance of a session ID conflict (though it seems very low), and if it happens, the first session would be overwritten (since we use Put).

@rosstimothy @greedy52 Do you think we should handle this case in CreateAppSessionForAppAuth and return an early error when there is a conflict?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should avoid conflicts if we can.

Do you think we should handle this case in CreateAppSessionForAppAuth and return an early error when there is a conflict

What kind of UX does this result in? What are end users expected to do to resolve this error? Can we prefix app auth sessions to eliminate the conflict entirely? Prefixing regular sessions is probably not feasible due to backward compatibility.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1 for prefix the app auth sessions then the length will be different

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.

I'll do this in a separate PR (opening it soon) so we can have a proper (and easier) review, and also better manual testing coverage. I'll update this comment once I have the PR up.

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.

PR up: #64457

Comment thread lib/web/app/handler.go Outdated
Comment thread lib/web/app/middleware.go Outdated
Comment thread lib/web/app/match.go Outdated
Comment thread lib/web/app/handler.go Outdated
Comment thread lib/web/app/handler.go Outdated
@rosstimothy
Copy link
Copy Markdown
Contributor

Could you please update the PR description with a manual test plan.

@greedy52 greedy52 self-requested a review February 18, 2026 19:30
Comment thread lib/web/app/match_test.go Outdated
Comment thread lib/web/app/match_test.go Outdated
Comment thread lib/web/app/match_test.go Outdated
Comment thread lib/web/app/handler.go Outdated
Comment thread lib/web/app/handler.go
Comment on lines +272 to +273
// Results and errors are sent by the handler.
return nil, nil
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If we never return an error will the limiter ever kick in?

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.

It will. The limiter we're using, WithUnauthenticatedLimiter, doesn't care about the handler's return value, since it only relies on the request. Other handlers like getWebConfig, which uses this same handler, also don't use the handler return for sending results.

I also did manual testing on this and confirmed the rate limiter is being applied and working as expected.

Comment thread lib/web/app/handler.go Outdated
Comment thread lib/web/app/match.go
return nil, trace.BadParameter("unable to resolve requested app by name")
}

return servers[0], nil
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we randomize the app server instead of returning the first?

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.

It won't make much difference here, since this app server is only used to retrieve the app configuration (which is later used to retrieve its labels). This follows the same behavior as existent flow with ResolveFQDN.

The HA flow will still construct an appropriate list of healthy app servers that can serve requests. There, we do the shuffling.

// Match healthy servers. Having a list of only healthy
// servers helps the transport fail before the request is forwarded to a
// server (in cases where there are no healthy servers). This process might
// take an additional time to execute, but since it is cached, only a few
// requests need to perform it.
servers = slices.DeleteFunc(servers, func(appServer types.AppServer) bool {
return !isAppServerDialable(ctx, clusterClient, appServer)
})
if len(servers) == 0 {
return nil, trace.NotFound("all app servers unheatlhy")
}
rand.Shuffle(len(servers), func(i, j int) {
servers[i], servers[j] = servers[j], servers[i]
})

Comment thread lib/web/app/handler.go
Comment on lines +622 to +631
for config, err := range clientutils.Resources(ctx, h.c.AccessPoint.ListAppAuthConfigs) {
if err != nil {
return nil, trace.Wrap(err, "unable to retrieve app auth configs")
}
convertedLabels := make(types.Labels)
for k, vs := range label.ToMap(config.Spec.AppLabels) {
convertedLabels[k] = apiutils.Strings(vs)
}

matched, message, err := services.MatchLabelGetter(convertedLabels, app)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The original idea is to limit app auth config to a small subset of your apps

Can we assume this will always hold? Should we design for a world where this is no longer the case now?

we came up with a solution that would avoid doing this matching (and app auth config listing) at every request and leave it only when starting a new session

Loading a single time would be better than on every request.

Comment thread lib/utils/mcptest/test.go Outdated
Comment thread lib/web/app/handler.go
Comment on lines +622 to +631
for config, err := range clientutils.Resources(ctx, h.c.AccessPoint.ListAppAuthConfigs) {
if err != nil {
return nil, trace.Wrap(err, "unable to retrieve app auth configs")
}
convertedLabels := make(types.Labels)
for k, vs := range label.ToMap(config.Spec.AppLabels) {
convertedLabels[k] = apiutils.Strings(vs)
}

matched, message, err := services.MatchLabelGetter(convertedLabels, app)
Copy link
Copy Markdown
Contributor

@greedy52 greedy52 Mar 2, 2026

Choose a reason for hiding this comment

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

Each app_auth_config is basically a new SSO connector but just for apps instead of users. I wouldn't imagine there is a world with many of them. That said, we do handle this well for SSO connectors by having a default connector in auth config or for user to explicitly select a connector name.

We could replicate the part for caller to provide a connector name here. That will require significant redesign and will not have the best UX as we have to embed extra info into the token.

What Gabriel and I have discussed is to drop the custom auth header so we always target Authorization. This way we can compute the session ID without the need to grab app_auth_config for existing sessions (so we only grab app_auth_config for new sessions). I like this because it also reduces number of knobs.

An alternative is to use a watcher similar to what Tyler did for database servers where the watcher serves as a cache we can easily loop through:
#63878

@rosstimothy what do you think about the watcher approach?

Comment thread lib/web/app/handler.go Outdated
func (h *Handler) getSessionWithAppAuth(ctx context.Context, ws types.WebSession, appAuthConfig *appauthconfigv1.AppAuthConfig) (*sessionWithAppAuth, error) {
// Put the session in the cache so the next request can use it.
ttl := ws.Expiry().Sub(h.c.Clock.Now())
sess, err := utils.FnCacheGetWithTTL(ctx, h.cache, ws.GetName(), ttl, func(ctx context.Context) (*sessionWithAppAuth, error) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1 for prefix the app auth sessions then the length will be different

Comment thread lib/web/app/middleware.go Outdated
@greedy52 greedy52 self-requested a review March 2, 2026 21:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-changelog Indicates that a PR does not require a changelog entry size/lg

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants