Skip to content

Commit 9e0034f

Browse files
ericdalloeca-agent
andcommitted
Add clientSecret, oauthPort and HTTPS support for MCP OAuth
Enables MCP servers that require confidential OAuth with pre-registered HTTPS redirect URIs (e.g. Slack MCP). - Add `clientSecret` config field, threaded through oauth-info, authorize-token! and refresh-token! as client_secret in token exchange - Add `oauthPort` config field for fixed callback port with automatic HTTPS via bundled self-signed localhost certificate (tls/localhost.p12) - Apply replace-env-vars to clientId/clientSecret for secret management - Fix pre-existing bug: stop-oauth-server! missing @ on atom deref - Handle BindException with user-friendly message for port conflicts - Fail fast with clear error if bundled keystore is missing 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca <git@eca.dev>
1 parent 631f22f commit 9e0034f

7 files changed

Lines changed: 140 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Fix `/resume` getting stuck when resumed chat had stale compaction flags.
6+
- Add `clientSecret` and `oauthPort` support for MCP servers requiring confidential OAuth with HTTPS pre-registered redirect URIs (e.g. Slack MCP).
67

78
## 0.122.0
89

docs/config.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,8 +1000,20 @@
10001000
},
10011001
"clientId": {
10021002
"type": "string",
1003-
"description": "OAuth client ID for servers that require a pre-registered OAuth application (e.g. Databricks). When set, dynamic client registration is skipped.",
1004-
"markdownDescription": "OAuth client ID for servers that require a pre-registered OAuth application (e.g. Databricks). When set, dynamic client registration is skipped."
1003+
"description": "OAuth client ID for servers that require a pre-registered OAuth application (e.g. Databricks, Slack). When set, dynamic client registration is skipped.",
1004+
"markdownDescription": "OAuth client ID for servers that require a pre-registered OAuth application (e.g. Databricks, Slack). When set, dynamic client registration is skipped."
1005+
},
1006+
"clientSecret": {
1007+
"type": "string",
1008+
"description": "OAuth client secret for servers that require confidential OAuth (e.g. Slack MCP). Used together with clientId for token exchange.",
1009+
"markdownDescription": "OAuth client secret for servers that require confidential OAuth (e.g. Slack MCP). Used together with `clientId` for token exchange."
1010+
},
1011+
"oauthPort": {
1012+
"type": "integer",
1013+
"minimum": 1,
1014+
"maximum": 65535,
1015+
"description": "Fixed port for the OAuth callback server. Required by providers like Slack that mandate pre-registered redirect URIs. When set, the callback uses HTTPS with a bundled localhost certificate. Register https://localhost:<port>/auth/callback as the redirect URI in your OAuth app.",
1016+
"markdownDescription": "Fixed port for the OAuth callback server. Required by providers like Slack that mandate pre-registered redirect URIs. When set, the callback uses HTTPS with a bundled localhost certificate. Register `https://localhost:<port>/auth/callback` as the redirect URI in your OAuth app."
10051017
}
10061018
},
10071019
"additionalProperties": false

docs/config/tools.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,37 @@ For MCP servers configuration, use the `mcpServers` config, examples:
6262
}
6363
```
6464

65+
=== "Confidential OAuth (e.g. Slack)"
66+
67+
Some providers (e.g. Slack MCP) require confidential OAuth with both a
68+
`clientId` and `clientSecret`, and require HTTPS pre-registered redirect URIs.
69+
70+
Setup steps:
71+
72+
1. Create a Slack App at [api.slack.com/apps](https://api.slack.com/apps)
73+
2. Enable MCP under *Features > Agents & AI Apps*
74+
3. Under *OAuth & Permissions > Redirect URLs*, add
75+
`https://localhost:19284/auth/callback`
76+
4. Add the user scopes you need (e.g. `search:read.public`, `channels:history`)
77+
5. Copy the credentials from *Settings > Basic Information > App Credentials*
78+
79+
When `oauthPort` is set, ECA uses a bundled localhost certificate to serve
80+
HTTPS on the callback. On first authorization, your browser will show a
81+
certificate warning — click *Advanced → Proceed* to complete the flow.
82+
83+
```javascript title="~/.config/eca/config.json"
84+
{
85+
"mcpServers": {
86+
"slack": {
87+
"url": "https://mcp.slack.com/mcp",
88+
"clientId": "<your-slack-app-client-id>",
89+
"clientSecret": "<your-slack-app-client-secret>",
90+
"oauthPort": 19284
91+
}
92+
}
93+
}
94+
```
95+
6596
=== "Static auth header"
6697

6798
For servers that accept a static token (e.g. a personal access token),

resources/META-INF/native-image/eca/eca/native-image.properties

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,5 @@ Args=-J-Dborkdude.dynaload.aot=true \
4242
-H:IncludeResources=webpages/oauth.html \
4343
-H:IncludeResources=logo.svg \
4444
-H:IncludeResources=tls/local-eca-dev-fullchain.pem \
45-
-H:IncludeResources=tls/local-eca-dev-privkey.pem
45+
-H:IncludeResources=tls/local-eca-dev-privkey.pem \
46+
-H:IncludeResources=tls/localhost.p12

resources/tls/localhost.p12

2.58 KB
Binary file not shown.

src/eca/features/tools/mcp.clj

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -262,16 +262,20 @@
262262
(defn ^:private try-refresh-token!
263263
"Attempt to refresh an MCP server's OAuth token.
264264
Returns true if refresh succeeded, false otherwise."
265-
[name db* url metrics {:keys [clientId]}]
265+
[name db* url metrics {:keys [clientId clientSecret oauthPort]}]
266266
(let [mcp-auth (get-in @db* [:mcp-auth name])
267267
{:keys [refresh-token]} mcp-auth]
268268
(when refresh-token
269269
(logger/info logger-tag (format "Attempting to refresh token for MCP server '%s'" name))
270-
(when-let [oauth-info (oauth/oauth-info (replace-env-vars url) clientId)]
270+
(when-let [oauth-info (oauth/oauth-info (replace-env-vars url)
271+
(some-> clientId replace-env-vars)
272+
(some-> clientSecret replace-env-vars)
273+
oauthPort)]
271274
(when-let [new-tokens (oauth/refresh-token!
272275
(:token-endpoint oauth-info)
273276
(:client-id oauth-info)
274277
refresh-token
278+
:client-secret (:client-secret oauth-info)
275279
:resource (:resource oauth-info))]
276280
(logger/info logger-tag (format "Successfully refreshed token for MCP server '%s'" name))
277281
(swap! db* assoc-in [:mcp-auth name]
@@ -327,7 +331,9 @@
327331
(and token-expired? (not refresh-succeeded?))))
328332
oauth-info (when (and url needs-oauth?)
329333
(oauth/oauth-info (replace-env-vars url)
330-
(:clientId server-config)))]
334+
(some-> (:clientId server-config) replace-env-vars)
335+
(some-> (:clientSecret server-config) replace-env-vars)
336+
(:oauthPort server-config)))]
331337
(if oauth-info
332338
(initialize-mcp-oauth oauth-info
333339
name
@@ -465,11 +471,12 @@
465471
(let [server-config (get-in config [:mcpServers name])
466472
{:keys [oauth-info]} (get-in @db* [:mcp-clients name])]
467473
(when (and server-config oauth-info)
468-
(let [{:keys [authorization-endpoint callback-port]} oauth-info]
474+
(let [{:keys [authorization-endpoint callback-port ssl?]} oauth-info]
469475
(swap! db* assoc-in [:mcp-clients name :status] :starting)
470476
(on-server-updated (->server name server-config :starting @db*))
471477
(oauth/start-oauth-server!
472478
{:port callback-port
479+
:ssl? ssl?
473480
:on-success (fn [{:keys [code]}]
474481
(let [{:keys [access-token refresh-token expires-at]} (oauth/authorize-token! oauth-info code)]
475482
(swap! db* assoc-in [:mcp-auth name] {:type :auth/oauth

src/eca/oauth.clj

Lines changed: 81 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
[ring.util.response :as response]
1313
[selmer.parser :as selmer])
1414
(:import
15+
[java.io File]
1516
[java.nio.charset StandardCharsets]
1617
[java.security MessageDigest SecureRandom]
1718
[java.util Base64]
@@ -195,14 +196,24 @@
195196

196197
(defn oauth-info
197198
"Perform OAuth discovery for the given MCP server URL.
198-
Optional `configured-client-id` skips dynamic client registration when provided."
199-
([^String url] (oauth-info url nil))
200-
([^String url configured-client-id]
199+
Optional `configured-client-id` skips dynamic client registration when provided.
200+
Optional `configured-client-secret` enables confidential OAuth (e.g. Slack MCP).
201+
Optional `configured-oauth-port` uses a fixed port for the OAuth callback (required
202+
by providers like Slack that mandate pre-registered redirect URIs)."
203+
([^String url] (oauth-info url nil nil))
204+
([^String url configured-client-id] (oauth-info url configured-client-id nil))
205+
([^String url configured-client-id configured-client-secret]
206+
(oauth-info url configured-client-id configured-client-secret nil))
207+
([^String url configured-client-id configured-client-secret configured-oauth-port]
201208
(let [base-url (url->base-url url)
202209
auth-response (probe-auth url)]
203210
(when-let [headers (:headers auth-response)]
204-
(let [callback-port (get-free-port)
205-
redirect-uri (format "http://localhost:%s/auth/callback" callback-port)
211+
(let [ssl? (some? configured-oauth-port)
212+
callback-port (if configured-oauth-port
213+
(int configured-oauth-port)
214+
(get-free-port))
215+
scheme (if ssl? "https" "http")
216+
redirect-uri (format "%s://localhost:%s/auth/callback" scheme callback-port)
206217
www-authenticate (some-> (get headers "www-authenticate") parse-www-authenticate)
207218
;; Step 1: Discover resource/auth metadata (PRM per RFC 9728, then legacy fallback)
208219
first-meta (discover-resource-metadata www-authenticate base-url)
@@ -219,15 +230,18 @@
219230
;; Skip DCR when a client-id is pre-configured
220231
new-client-id (when-not configured-client-id
221232
(when-let [reg-endpoint (:registration_endpoint meta)]
222-
(let [res (http/post
233+
(let [auth-method (if configured-client-secret
234+
"client_secret_post"
235+
"none")
236+
res (http/post
223237
reg-endpoint
224238
{:timeout 10000
225239
:as :json
226240
:throw-exceptions? false
227241
:headers {"Content-Type" "application/json"}
228242
:body (json/generate-string
229243
{:redirect_uris [redirect-uri]
230-
:token_endpoint_auth_method "none"
244+
:token_endpoint_auth_method auth-method
231245
:grant_types ["authorization_code" "refresh_token"]
232246
:response_types ["code"]
233247
:client_name "ECA (Editor Code Assistant)"
@@ -251,44 +265,74 @@
251265
:redirect_uri redirect-uri
252266
:resource url}
253267
scope (assoc :scope scope)))]
254-
{:callback-port callback-port
255-
:token-endpoint (or (:token_endpoint meta)
256-
(str auth-server "/access_token"))
257-
:verifier verifier
258-
:client-id client-id
259-
:redirect-uri redirect-uri
260-
:resource url
261-
:authorization-endpoint (str base-auth-endpoint "?" query-params)})))))))
268+
(cond-> {:callback-port callback-port
269+
:token-endpoint (or (:token_endpoint meta)
270+
(str auth-server "/access_token"))
271+
:verifier verifier
272+
:client-id client-id
273+
:redirect-uri redirect-uri
274+
:resource url
275+
:authorization-endpoint (str base-auth-endpoint "?" query-params)}
276+
ssl? (assoc :ssl? true)
277+
configured-client-secret (assoc :client-secret configured-client-secret)))))))))
262278

263279
(comment
264280
(oauth-info "https://mcp.atlassian.com/v1/sse")
265281
(oauth-info "https://mcp.miro.com/")
266282
(oauth-info "https://api.githubcopilot.com/mcp/"))
267283

284+
(defn ^:private extract-keystore-to-temp
285+
"Extract the bundled localhost PKCS12 keystore to a temp file.
286+
Returns the absolute path to the temp file."
287+
^String []
288+
(let [res (io/resource "tls/localhost.p12")]
289+
(when-not res
290+
(throw (ex-info "Bundled localhost keystore not found on classpath"
291+
{:resource "tls/localhost.p12"})))
292+
(let [temp-file (File/createTempFile "eca-oauth-" ".p12")]
293+
(.deleteOnExit temp-file)
294+
(with-open [in (io/input-stream res)]
295+
(io/copy in temp-file))
296+
(.getAbsolutePath temp-file))))
297+
268298
(defn start-oauth-server!
269-
"Start local server on port to handle OAuth redirect"
270-
[{:keys [on-error on-success port]}]
299+
"Start local server on port to handle OAuth redirect.
300+
When :ssl? is true, starts HTTPS using the bundled localhost certificate."
301+
[{:keys [on-error on-success port ssl?]}]
271302
(when-not (get @oauth-server-by-port* port)
272-
(let [handler (-> oauth-handler
273-
wrap-keyword-params
274-
wrap-params)
275-
server (jetty/run-jetty
276-
(fn [request]
277-
(if (= "/auth/callback" (:uri request))
278-
(handler request on-success on-error)
279-
(-> (response/response "404 Not Found")
280-
(response/status 404))))
281-
{:port (or port (get-free-port))
282-
:join? false})]
283-
(swap! oauth-server-by-port* assoc port server)
284-
(logger/info logger-tag (str "OAuth server started on http://localhost:" port))
285-
{:server server
286-
:port port})))
303+
(try
304+
(let [handler (-> oauth-handler
305+
wrap-keyword-params
306+
wrap-params)
307+
request-handler (fn [request]
308+
(if (= "/auth/callback" (:uri request))
309+
(handler request on-success on-error)
310+
(-> (response/response "404 Not Found")
311+
(response/status 404))))
312+
jetty-opts (if ssl?
313+
{:ssl? true
314+
:ssl-port (or port (get-free-port))
315+
:http? false
316+
:keystore (extract-keystore-to-temp)
317+
:key-password "ecalocal"
318+
:keystore-type "PKCS12"
319+
:join? false}
320+
{:port (or port (get-free-port))
321+
:join? false})
322+
server (jetty/run-jetty request-handler jetty-opts)
323+
scheme (if ssl? "https" "http")]
324+
(swap! oauth-server-by-port* assoc port server)
325+
(logger/info logger-tag (str "OAuth server started on " scheme "://localhost:" port))
326+
{:server server
327+
:port port})
328+
(catch java.net.BindException _
329+
(logger/error logger-tag (format "Port %d is already in use — is another ECA instance running?" port))
330+
nil))))
287331

288332
(defn stop-oauth-server!
289333
"Stop the local OAuth server"
290334
[port]
291-
(when-let [^Server server (get oauth-server-by-port* port)]
335+
(when-let [^Server server (get @oauth-server-by-port* port)]
292336
(.stop server)
293337
(swap! oauth-server-by-port* dissoc port)
294338
(logger/info logger-tag "OAuth server stopped")))
@@ -317,7 +361,7 @@
317361
;; Fallback to raw error
318362
{:raw_error body-str})))
319363

320-
(defn authorize-token! [{:keys [token-endpoint verifier client-id redirect-uri resource]} code]
364+
(defn authorize-token! [{:keys [token-endpoint verifier client-id client-secret redirect-uri resource]} code]
321365
(let [{:keys [status body]} (http/post
322366
token-endpoint
323367
{:headers {"Content-Type" "application/x-www-form-urlencoded"
@@ -328,6 +372,7 @@
328372
:code code
329373
:code_verifier verifier
330374
:redirect_uri redirect-uri}
375+
client-secret (assoc :client_secret client-secret)
331376
resource (assoc :resource resource)))
332377
:throw-exceptions? false
333378
:as :stream})
@@ -367,7 +412,7 @@
367412
(defn refresh-token!
368413
"Refresh an OAuth access token using a refresh token.
369414
Returns {:access-token :refresh-token :expires-at} on success, nil if refresh fails."
370-
[token-endpoint client-id refresh-token & {:keys [resource]}]
415+
[token-endpoint client-id refresh-token & {:keys [client-secret resource]}]
371416
(try
372417
(let [{:keys [status body]} (http/post
373418
token-endpoint
@@ -377,6 +422,7 @@
377422
(cond-> {:grant_type "refresh_token"
378423
:client_id client-id
379424
:refresh_token refresh-token}
425+
client-secret (assoc :client_secret client-secret)
380426
resource (assoc :resource resource)))
381427
:throw-exceptions? false
382428
:as :stream})

0 commit comments

Comments
 (0)