Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- MCP OAuth: add `authScope` (`global` default / `workspace` / custom bucket) to namespace stored tokens, so signing into different accounts per project no longer clobbers a shared server's token.

## 0.139.1

- Model fetch: skip providers without configured credentials, include the response body in 4xx/5xx logs, and quiet down per-provider logging.
Expand Down
5 changes: 5 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -1217,6 +1217,11 @@
"type": "string",
"description": "Override the client_name sent during OAuth Dynamic Client Registration. Required for servers that allowlist clients by name (e.g. Figma). Defaults to 'ECA (Editor Code Assistant)' when not set.",
"markdownDescription": "Override the `client_name` sent during OAuth Dynamic Client Registration. Required for servers that allowlist clients by name (e.g. Figma). Defaults to `ECA (Editor Code Assistant)` when not set."
},
"authScope": {
"type": "string",
"description": "Controls how the OAuth token for this server is shared across projects. 'global' (default): one token shared everywhere. 'workspace': a separate token per workspace folder set, so authenticating different accounts in different projects does not clobber each other. Any other value defines a named bucket shared by every project that uses the same value. Like other config values, supports dynamic-string interpolation (${env:...}, ${file:...}, ${cmd:...}, etc.).",
"markdownDescription": "Controls how the OAuth token for this server is shared across projects. `global` (default): one token shared everywhere. `workspace`: a separate token per workspace folder set, so authenticating different accounts in different projects does not clobber each other. Any other value defines a named bucket shared by every project that uses the same value. Like other config values, supports dynamic-string interpolation (`${env:...}`, `${file:...}`, `${cmd:...}`, etc.)."
}
},
"additionalProperties": false
Expand Down
26 changes: 26 additions & 0 deletions docs/config/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,32 @@ For MCP servers configuration, use the `mcpServers` config, examples:
The DCR attempt, its result and the chosen `client_name` are logged at
`info`/`warn` level so you can verify behavior in the ECA log.

=== "Per-project auth — authScope"

By default a server's OAuth token is shared across every project, which is
convenient when the same account is used everywhere. If you sign in to a
*different* account (e.g. a different Linear workspace) per project, the
shared token would otherwise be clobbered. Set `authScope` to control this:

- `global` (default): one token shared across all projects.
- `workspace`: a separate token per workspace folder set.
- any other value: a named bucket shared by every project using that value.

```javascript title="myproject/.eca/config.json"
{
"mcpServers": {
"linear": {
"url": "https://mcp.linear.app/mcp",
"authScope": "workspace"
}
}
}
```

Like other config values, `authScope` supports the full dynamic-string
interpolation (`${env:...}`, `${file:...}`, `${cmd:...}`, `${netrc:...}`,
`${classpath:...}`), e.g. `"authScope": "${env:LINEAR_ORG}"`.

=== "Disabling a MCP"

Set `"disabled": true` to keep the configuration but prevent ECA from starting the server.
Expand Down
8 changes: 8 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -2899,6 +2899,14 @@ interface MCPAddServerParams {
*/
oauthPort?: number;

/**
* Controls how the OAuth token is shared across projects (Streamable HTTP
* transport). "global" (default) shares one token everywhere; "workspace"
* keeps a separate token per workspace folder set; any other value defines
* a named bucket shared by every project using the same value.
*/
authScope?: string;

/**
* Whether the new server should be created in a disabled state.
*/
Expand Down
72 changes: 53 additions & 19 deletions src/eca/features/tools/mcp.clj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
[clojure.java.browse :as browse]
[clojure.java.io :as io]
[clojure.string :as string]
[eca.cache :as cache]
[eca.config :as config]
[eca.db :as db]
[eca.logger :as logger]
Expand Down Expand Up @@ -110,6 +111,36 @@
(str "$" var1)
(str "${" var2 "}"))))))

(defn ^:private mcp-auth-key
"Derive the `:mcp-auth` storage key for a server.

Tokens are namespaced so distinct projects don't clobber each other while
still sharing by default. Controlled by the server's `:authScope`:
- nil / \"global\" (default): bare server name, so a single token is shared
across every project (and back-compat with previously stored tokens).
- \"workspace\": scoped to the current workspace folder set.
- any other value: a named bucket shared across every project that uses it.

The value is resolved like any other config string, so the full
dynamic-string interpolation (`${env:...}`, `${file:...}`, `${cmd:...}`, ...)
already ran at config-load time; `replace-env-vars` here additionally expands
bare `$VAR` forms, matching how `url`/`headers` are treated.

The server name always prefixes the composite key so two different servers
never share a token even under the same scope label."
[server-name server-config db]
(let [scope (some-> (:authScope server-config) replace-env-vars string/trim not-empty)]
(cond
(or (nil? scope) (= "global" scope))
server-name

(= "workspace" scope)
(str (name server-name) "#ws:"
(cache/workspaces-hash (:workspace-folders db) shared/uri->filename))

:else
(str (name server-name) "#scope:" scope))))

(defn ^:private ->transport [server-name server-config workspaces db*]
(if (:url server-config)
;; HTTP Streamable transport
Expand All @@ -125,7 +156,7 @@
[(name k) (replace-env-vars (str v))]))
config-headers))
(update :headers merge
(when-let [access-token (get-in @db* [:mcp-auth server-name :access-token])]
(when-let [access-token (get-in @db* [:mcp-auth (mcp-auth-key server-name server-config @db*) :access-token])]
{"Authorization" (str "Bearer " access-token)}))))
hc (phc/make-http-client url (cond-> {:request-middleware rm
:timeout-millis 10000}
Expand Down Expand Up @@ -228,7 +259,7 @@
:prompts (get-in db [:mcp-clients mcp-name :prompts])
:resources (get-in db [:mcp-clients mcp-name :resources])
:instructions (get-in db [:mcp-clients mcp-name :instructions])
:has-auth (boolean (get-in db [:mcp-auth mcp-name :access-token]))
:has-auth (boolean (get-in db [:mcp-auth (mcp-auth-key mcp-name server-config db) :access-token]))
:disabled (boolean (:disabled server-config))
:status status})

Expand Down Expand Up @@ -333,8 +364,9 @@
(defn ^:private try-refresh-token!
"Attempt to refresh an MCP server's OAuth token.
Returns true if refresh succeeded, false otherwise."
[name db* url metrics {:keys [clientId clientSecret oauthPort clientName]}]
(let [mcp-auth (get-in @db* [:mcp-auth name])
[name db* url metrics {:keys [clientId clientSecret oauthPort clientName] :as server-config}]
(let [auth-key (mcp-auth-key name server-config @db*)
mcp-auth (get-in @db* [:mcp-auth auth-key])
{:keys [refresh-token]} mcp-auth
;; Reuse the client the refresh token was minted under instead of running
;; Dynamic Client Registration again. Servers with non-idempotent DCR (e.g.
Expand All @@ -356,7 +388,7 @@
:client-secret (:client-secret oauth-info)
:resource (:resource oauth-info))]
(logger/info logger-tag (format "Successfully refreshed token for MCP server '%s'" name))
(swap! db* assoc-in [:mcp-auth name]
(swap! db* assoc-in [:mcp-auth auth-key]
(merge mcp-auth new-tokens))
(db/update-global-cache! @db* metrics)
true)))))
Expand Down Expand Up @@ -394,7 +426,7 @@
url (:url server-config)
;; Skip OAuth entirely if Authorization header is configured
has-static-auth? (some-> server-config :headers :Authorization some?)
mcp-auth (get-in @db* [:mcp-auth name])
mcp-auth (get-in @db* [:mcp-auth (mcp-auth-key name server-config @db*)])
;; Invalidate cached credentials when base URL changed (ignore query params)
mcp-auth (when (= (oauth/url-without-query url)
(oauth/url-without-query (:url mcp-auth))) mcp-auth)
Expand Down Expand Up @@ -576,19 +608,20 @@
;; Callbacks run on a background thread after the OAuth HTML page has been
;; flushed to the browser, so synchronous stop-oauth-server! is safe here.
:on-success (fn [{:keys [code]}]
(let [{:keys [access-token refresh-token expires-at]} (oauth/authorize-token! oauth-info code)]
(let [{:keys [access-token refresh-token expires-at]} (oauth/authorize-token! oauth-info code)
auth-key (mcp-auth-key name server-config @db*)]
;; Persist the client identity the tokens were minted under so refreshes
;; reuse it. Servers with non-idempotent Dynamic Client Registration (e.g.
;; RunLayer) issue a new client_id per registration; without this, a later
;; refresh would re-register and orphan the refresh token.
(swap! db* assoc-in [:mcp-auth name] (cond-> {:type :auth/oauth
:url (:url server-config)
:refresh-token refresh-token
:access-token access-token
:expires-at expires-at
:client-id (:client-id oauth-info)}
(:client-secret oauth-info)
(assoc :client-secret (:client-secret oauth-info)))))
(swap! db* assoc-in [:mcp-auth auth-key] (cond-> {:type :auth/oauth
:url (:url server-config)
:refresh-token refresh-token
:access-token access-token
:expires-at expires-at
:client-id (:client-id oauth-info)}
(:client-secret oauth-info)
(assoc :client-secret (:client-secret oauth-info)))))
(oauth/stop-oauth-server! callback-port)
(db/update-global-cache! @db* metrics)
(initialize-server! name db* config metrics on-server-updated))
Expand Down Expand Up @@ -648,7 +681,7 @@
(try
(when (get-in @db* [:mcp-clients server-name :client])
(stop-server! server-name db* prev-config {:on-server-updated on-server-updated}))
(swap! db* update :mcp-auth dissoc server-name)
(swap! db* update :mcp-auth dissoc (mcp-auth-key server-name (get old k) @db*))
(when on-server-removed
(on-server-removed {:name server-name}))
(catch Exception e
Expand All @@ -669,8 +702,8 @@
(defn logout-server!
"Logout from an MCP server by clearing stored OAuth credentials and restarting it."
[name db* config metrics {:keys [on-server-updated]}]
(when (get-in config [:mcpServers name])
(swap! db* update :mcp-auth dissoc name)
(when-let [server-config (get-in config [:mcpServers name])]
(swap! db* update :mcp-auth dissoc (mcp-auth-key name server-config @db*))
(db/update-global-cache! @db* metrics)
(restart-server! name db* config metrics on-server-updated)))

Expand Down Expand Up @@ -868,10 +901,11 @@
(throw (ex-info (format "MCP server '%s' does not exist" server-name)
{:server-name server-name})))
(let [db @db*
server-config (get-in config [:mcpServers server-name])
config-file (resolve-config-file server-name db)]
(when (get-in db [:mcp-clients server-name :client])
(stop-server! server-name db* config {:on-server-updated on-server-updated}))
(swap! db* update :mcp-auth dissoc server-name)
(swap! db* update :mcp-auth dissoc (mcp-auth-key server-name server-config db))
(update-config-file! config-file
#(rj/dissoc-in % ["mcpServers" server-name]))
(memoize/memo-clear! config/all)
Expand Down
Loading