Skip to content
Open
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
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ Uses the official `mcp` gem's `MCP::Server::Transports::StreamableHTTPTransport`

### Controller (`app/controllers/mcp_controller.rb`)

Two endpoints:
- `POST /mcp/token` — unauthenticated; exchanges username+password for the user's Redmine API key.
- `GET/POST/DELETE /mcp` — Streamable HTTP transport; handles all MCP protocol messages. Requires `Authorization: Bearer <key>` or `X-Redmine-API-Key: <key>`.
Endpoints:
- `GET/POST/DELETE /mcp` — Streamable HTTP transport; handles all MCP protocol messages. Requires a Bearer token.
- `GET /.well-known/oauth-protected-resource[/mcp]` and `GET /.well-known/oauth-authorization-server` — unauthenticated OAuth discovery (RFC 9728 / RFC 8414) so claude.ai custom connectors can find Redmine's doorkeeper endpoints.

Authentication reads the token, calls `User.find_by_api_key`, and sets `User.current` before the request is dispatched.
Authentication tries a doorkeeper OAuth token first (`Doorkeeper.authenticate`, sets `oauth_scope` like Redmine core does), then falls back to a static Redmine API key, then sets `User.current`. A 401 carries `WWW-Authenticate: Bearer resource_metadata="…"` so clients can discover OAuth.

### Tools (`lib/redmine_mcp/tools/`)

Expand Down
104 changes: 68 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@

| 方法 | 路徑 | 說明 |
|---|---|---|
| `POST` | `/mcp/token` | 以帳號密碼換取 API Key(免驗證) |
| `GET` / `POST` / `DELETE` | `/mcp` | Streamable HTTP 傳輸,處理所有 MCP 協定訊息 |
| `GET` | `/.well-known/oauth-protected-resource` | OAuth 資源中繼資料(RFC 9728,免驗證) |
| `GET` | `/.well-known/oauth-authorization-server` | OAuth 授權伺服器中繼資料(RFC 8414,免驗證) |

### 安裝與設定

Expand All @@ -37,21 +38,9 @@ rails server

**3. 取得 API Key**

```bash
curl -X POST https://your-redmine.example.com/mcp/token \
-H "Content-Type: application/json" \
-d '{"username": "your_login", "password": "your_password"}'
```

```json
{
"access_token": "abc123...",
"token_type": "Bearer",
"user": { "id": 1, "login": "admin", "name": "Administrator" }
}
```
進入 Redmine 個人設定頁面(右上角「我的帳戶」)→ 右側「API 存取金鑰」→ 顯示。

**4. 設定 MCP 客戶端(Claude Desktop)**
**4. 設定 MCP 客戶端(Claude Code / Claude Desktop)**

```json
{
Expand All @@ -66,14 +55,41 @@ curl -X POST https://your-redmine.example.com/mcp/token \
}
```

### 接上 claude.ai custom connector(OAuth)

claude.ai 只接受「公網 HTTPS + OAuth」,不能貼固定的 API Key,需改用 Redmine 內建的 OAuth(doorkeeper,Redmine / RedMica 6.1+)。

**1. 在 Redmine 建立 OAuth 應用程式**

管理後台 → 應用程式 → 新增:

- 重新導向 URI:`https://claude.ai/api/mcp/auth_callback`
- 機密(Confidential):勾選
- 授權範圍(Scopes):留空即可(留空時允許客戶端要求任何已設定的權限)

建立後會拿到 **Client ID** 與 **Client Secret**。

**2. 在 claude.ai 新增 connector**

Settings → Connectors → Add custom connector:

- URL:`https://your-redmine.example.com/mcp`
- Advanced settings → 填入上一步的 Client ID / Client Secret

按下 Connect 後會導到 Redmine 登入並授權,完成後即可使用。

### 驗證方式

所有 MCP 請求皆需帶上 `Authorization: Bearer <api_key>`(或 `X-Redmine-API-Key: <key>`)標頭。
`POST /mcp/token` 是唯一不需驗證的端點,用於將帳號密碼換成 API Key。
所有 MCP 請求皆需帶上下列其中一種:

- `Authorization: Bearer <oauth_access_token>` — Redmine doorkeeper 發出的 OAuth token(claude.ai 用)
- `Authorization: Bearer <api_key>` 或 `X-Redmine-API-Key: <key>` — 固定 API Key(Claude Code / Desktop 用)

驗證失敗時會回 `401` 並帶 `WWW-Authenticate` 標頭,指向上述的 OAuth 中繼資料端點,讓客戶端自動發現 OAuth 流程。

### 系統需求

- Redmine / RedMica 5.0+
- Redmine / RedMica 5.0+(OAuth 需 6.1+)
- Ruby 3.2+

---
Expand All @@ -93,8 +109,9 @@ A Redmine plugin that exposes a [Model Context Protocol (MCP)](https://modelcont

| Method | Path | Description |
|---|---|---|
| `POST` | `/mcp/token` | Exchange username+password → API key (unauthenticated) |
| `GET` / `POST` / `DELETE` | `/mcp` | Streamable HTTP transport; handles all MCP protocol messages |
| `GET` | `/.well-known/oauth-protected-resource` | OAuth protected resource metadata (RFC 9728, unauthenticated) |
| `GET` | `/.well-known/oauth-authorization-server` | OAuth authorization server metadata (RFC 8414, unauthenticated) |

## Setup

Expand All @@ -111,21 +128,9 @@ In Redmine admin → Settings → API → enable REST web service.

### 3. Get API Key

```bash
curl -X POST https://your-redmine.example.com/mcp/token \
-H "Content-Type: application/json" \
-d '{"username": "your_login", "password": "your_password"}'
```

```json
{
"access_token": "abc123...",
"token_type": "Bearer",
"user": { "id": 1, "login": "admin", "name": "Administrator" }
}
```
Redmine → My account → right column "API access key" → Show.

### 4. Configure MCP Client (Claude Desktop)
### 4. Configure MCP Client (Claude Code / Claude Desktop)

```json
{
Expand All @@ -140,12 +145,39 @@ curl -X POST https://your-redmine.example.com/mcp/token \
}
```

## Connecting a claude.ai custom connector (OAuth)

claude.ai only accepts public HTTPS endpoints with OAuth — it cannot send a static API key. Use Redmine's built-in OAuth server (doorkeeper, Redmine / RedMica 6.1+).

### 1. Create an OAuth application in Redmine

Admin → Applications → New:

- Redirect URI: `https://claude.ai/api/mcp/auth_callback`
- Confidential: checked
- Scopes: leave empty (an empty list lets the client request any configured permission)

You get a **Client ID** and **Client Secret**.

### 2. Add the connector on claude.ai

Settings → Connectors → Add custom connector:

- URL: `https://your-redmine.example.com/mcp`
- Advanced settings: paste the Client ID / Client Secret from step 1

Hit Connect — you'll be redirected to Redmine to log in and authorize.

## Authentication

All MCP requests require `Authorization: Bearer <api_key>` (or `X-Redmine-API-Key: <key>`).
`POST /mcp/token` is the only unauthenticated endpoint — use it to exchange credentials for the key.
Every MCP request needs one of:

- `Authorization: Bearer <oauth_access_token>` — a doorkeeper OAuth token (claude.ai)
- `Authorization: Bearer <api_key>` or `X-Redmine-API-Key: <key>` — a static API key (Claude Code / Desktop)

On failure the server returns `401` with a `WWW-Authenticate` header pointing at the metadata endpoints above, so clients can discover the OAuth flow automatically.

## Requirements

- Redmine / RedMica 5.0+
- Redmine / RedMica 5.0+ (6.1+ for OAuth)
- Ruby 3.2+
100 changes: 66 additions & 34 deletions app/controllers/mcp_controller.rb
Original file line number Diff line number Diff line change
@@ -1,31 +1,18 @@
# frozen_string_literal: true

class McpController < ApplicationController
# Scopes advertised to OAuth clients. Clients that request no scope only get
# Redmine's public permissions, which is not enough for any tool here.
MCP_SCOPES = %w[
view_project edit_project
view_issues add_issues edit_issues delete_issues
view_time_entries log_time edit_time_entries
admin
].freeze

skip_before_action :verify_authenticity_token
skip_before_action :check_if_login_required
before_action :authenticate_mcp_user!, except: :token

# POST /mcp/token
# Exchange username+password for the user's Redmine API key (Bearer token).
def token
body = request.content_type&.include?('json') ? json_body : {}
login = params[:username] || body['username']
passwd = params[:password] || body['password']

return render json: { error: 'username and password are required' }, status: :bad_request \
unless login.present? && passwd.present?

user = User.try_to_login(login.to_s, passwd.to_s)
if user
render json: {
access_token: user.api_key,
token_type: 'Bearer',
user: { id: user.id, login: user.login, name: user.name }
}
else
render json: { error: 'Invalid credentials' }, status: :unauthorized
end
end
before_action :authenticate_mcp_user!, except: [:resource_metadata, :as_metadata]

# GET/POST/DELETE /mcp
# Streamable HTTP transport — handles all MCP protocol messages.
Expand All @@ -36,32 +23,77 @@ def handle
tools: RedmineMcp::Tool.descendants,
server_context: { user: User.current }
)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
# ponytail: Rails' own `config.hosts` already does host authorization, so the
# transport's loopback-only Host allowlist would just reject every real deployment.
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
server, stateless: true, dns_rebinding_protection: false
)
status, headers, body = transport.handle_request(request)
response.headers.merge!(headers)
render json: body.first, status: status
end

# GET /.well-known/oauth-protected-resource — RFC 9728
def resource_metadata
render json: {
resource: mcp_url,
authorization_servers: [base_url],
bearer_methods_supported: %w[header],
scopes_supported: MCP_SCOPES
}
end

# GET /.well-known/oauth-authorization-server — RFC 8414
# Redmine's own doorkeeper install is the authorization server.
def as_metadata
render json: {
issuer: base_url,
authorization_endpoint: oauth_authorization_url,
token_endpoint: oauth_token_url,
response_types_supported: %w[code],
grant_types_supported: %w[authorization_code refresh_token],
code_challenge_methods_supported: %w[S256],
token_endpoint_auth_methods_supported: %w[client_secret_post client_secret_basic],
scopes_supported: MCP_SCOPES
}
end

private

def authenticate_mcp_user!
auth = request.env['HTTP_AUTHORIZATION']
match = auth&.match(/\ABearer (.+)\z/i)
bearer = match ? match.captures.first.strip : nil
token = bearer || request.env['HTTP_X_REDMINE_API_KEY']&.strip

user = token.present? ? User.find_by_api_key(token) : nil
user = oauth_user || api_key_user

if user&.active?
User.current = user
else
response.headers['WWW-Authenticate'] = %(Bearer resource_metadata="#{mcp_resource_metadata_url}")
render json: { error: 'Unauthorized' }, status: :unauthorized
end
end

def json_body
@json_body ||= JSON.parse(request.body.read)
rescue JSON::ParserError
{}
# Doorkeeper OAuth token (Redmine 6.1+). Used by claude.ai custom connectors.
def oauth_user
return nil unless defined?(Doorkeeper)

token = Doorkeeper.authenticate(request)
return nil unless token&.accessible?

user = User.active.find_by(id: token.resource_owner_id)
user&.oauth_scope = token.scopes.all.map(&:to_sym)
user
end

# Static Redmine API key, for clients that can send a fixed header
# (Claude Code / Claude Desktop).
def api_key_user
bearer = request.env['HTTP_AUTHORIZATION'].to_s[/\ABearer (.+)\z/i, 1]
token = bearer&.strip.presence || request.env['HTTP_X_REDMINE_API_KEY']&.strip
token.present? ? User.find_by_api_key(token) : nil
end

# ponytail: assumes Redmine is served at the domain root; a sub-URI install
# would need RFC 8414 path-insertion for the well-known URLs.
def base_url
root_url.chomp('/')
end
end
8 changes: 6 additions & 2 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# frozen_string_literal: true

RedmineApp::Application.routes.draw do
post 'mcp/token', to: 'mcp#token'
match 'mcp', to: 'mcp#handle', via: %i[get post delete]
match 'mcp', to: 'mcp#handle', via: [:get, :post, :delete], as: :mcp

# OAuth discovery so MCP clients can find Redmine's built-in OAuth server.
get '.well-known/oauth-protected-resource', to: 'mcp#resource_metadata', as: :mcp_resource_metadata
get '.well-known/oauth-protected-resource/mcp', to: 'mcp#resource_metadata', as: :mcp_resource_metadata_scoped
get '.well-known/oauth-authorization-server', to: 'mcp#as_metadata', as: :mcp_authorization_server_metadata
end
56 changes: 56 additions & 0 deletions test/integration/oauth_discovery_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

require File.expand_path('../../../../test/test_helper', __dir__)

class OauthDiscoveryTest < Redmine::IntegrationTest
fixtures :users, :email_addresses

def test_protected_resource_metadata_is_public
get '/.well-known/oauth-protected-resource'
assert_response :success
json = JSON.parse(response.body)
assert_equal "#{root_url.chomp('/')}/mcp", json['resource']
assert_includes json['authorization_servers'], root_url.chomp('/')
end

def test_authorization_server_metadata_points_at_doorkeeper
get '/.well-known/oauth-authorization-server'
assert_response :success
json = JSON.parse(response.body)
assert_equal "#{root_url.chomp('/')}/oauth/authorize", json['authorization_endpoint']
assert_equal "#{root_url.chomp('/')}/oauth/token", json['token_endpoint']
assert_includes json['code_challenge_methods_supported'], 'S256'
end

def test_unauthenticated_mcp_request_advertises_oauth
post '/mcp'
assert_response :unauthorized
assert_equal %(Bearer resource_metadata="#{root_url.chomp('/')}/.well-known/oauth-protected-resource"),
response.headers['WWW-Authenticate']
end

def test_api_key_still_authenticates
user = User.find(1)
user.api_key # generate

post '/mcp',
params: { jsonrpc: '2.0', id: 1, method: 'tools/list' }.to_json,
headers: { 'CONTENT_TYPE' => 'application/json',
'HTTP_AUTHORIZATION' => "Bearer #{user.api_key}" }
assert_response :success
assert JSON.parse(response.body).dig('result', 'tools').present?
end

def test_oauth_token_authenticates
app = Doorkeeper::Application.create!(name: 'mcp-test', scopes: 'view_issues',
redirect_uri: 'https://claude.ai/api/mcp/auth_callback')
token = Doorkeeper::AccessToken.create!(application: app, resource_owner_id: 1, scopes: 'view_issues')

post '/mcp',
params: { jsonrpc: '2.0', id: 1, method: 'tools/list' }.to_json,
headers: { 'CONTENT_TYPE' => 'application/json',
'HTTP_AUTHORIZATION' => "Bearer #{token.plaintext_token}" }
assert_response :success
assert JSON.parse(response.body).dig('result', 'tools').present?
end
end