Skip to content

feat: add MCP server with OAuth 2.1 authentication#6266

Open
danielgatis wants to merge 4 commits intoshellhub-io:masterfrom
danielgatis:feat/mcp-server
Open

feat: add MCP server with OAuth 2.1 authentication#6266
danielgatis wants to merge 4 commits intoshellhub-io:masterfrom
danielgatis:feat/mcp-server

Conversation

@danielgatis
Copy link
Copy Markdown
Contributor

@danielgatis danielgatis commented Apr 30, 2026

Summary

ShellHub now exposes an MCP (Model Context Protocol) server so AI assistants (Claude Desktop, Claude Code, Cursor, etc.) can manage devices, sessions, and namespaces directly.

The MCP server runs inside the api service — no extra binary or sidecar needed. It uses OAuth 2.1 Authorization Code + PKCE for authentication, issuing the same JWT the rest of ShellHub uses.

demo.mp4

How it works

MCP Client              ShellHub API
    |                        |
    |-- GET /mcp ----------->|  ← 401 + WWW-Authenticate header
    |<-- 401 Unauthorized ---|
    |                        |
    |-- GET /.well-known/oauth-authorization-server
    |<-- { issuer, authorization_endpoint, token_endpoint, ... }
    |                        |
    |-- GET /api/oauth/authorize?client_id=...&code_challenge=...
    |<-- 302 → /login?oauth_client_id=...  (user logs in in browser)
    |                        |
    |  [user authenticates]  |
    |<-- authorization code  |
    |                        |
    |-- POST /api/oauth/token (code + code_verifier)
    |<-- { access_token: "<shellhub-jwt>", token_type: "Bearer" }
    |                        |
    |-- GET /mcp  (Authorization: Bearer <jwt>)
    |<-- SSE stream + tool list

The access_token is a standard ShellHub JWT (RS256, 72 h). The MCP server validates it on every request.

Connect Claude Code (CLI)

claude mcp add --transport http shellhub https://<shellhub-host>/mcp

Or via the MCP Inspector to test:

npx @modelcontextprotocol/inspector https://<shellhub-host>/mcp

Tool reference

All tools require a valid Bearer token. Permission levels: observer < operator < administrator < owner.

Device tools

shellhub_list_devices

List devices in the namespace.

Input Type Required Description
status string no Filter: accepted, pending, rejected, removed, unused
page integer no Page number (1-based, default: 1)
per_page integer no Results per page (default: 20, max: 100)

Minimum permission: observer

Output: { "total": N, "devices": [...] }

shellhub_get_device

Get full details of a single device.

Input Type Required Description
uid string yes Device UID

Minimum permission: observer

Output: Device object with status, tags, last seen, namespace, etc.

shellhub_update_device_status

Accept or reject a pending device.

Input Type Required Description
uid string yes Device UID
status string yes accepted or rejected

Minimum permission: operator (accept), operator (reject)

shellhub_delete_device

Permanently delete a device. Irreversible.

Input Type Required Description
uid string yes Device UID

Minimum permission: administrator

shellhub_rename_device

Rename a device (changes its hostname-style name).

Input Type Required Description
uid string yes Device UID
name string yes New name (hostname format, e.g. my-server)

Minimum permission: operator

shellhub_get_stats

Get namespace statistics.

Minimum permission: any authenticated user

Output:

{
  "registered_devices": 42,
  "online_devices": 17,
  "pending_devices": 3,
  "rejected_devices": 1,
  "active_sessions": 5
}

Session tools

shellhub_list_sessions

List SSH sessions (active and historical).

Input Type Required Description
page integer no Page (default: 1)
per_page integer no Per page (default: 20)

Minimum permission: observer

Output: { "total": N, "sessions": [...] }

shellhub_get_session

Get details of a specific session.

Input Type Required Description
uid string yes Session UID

Minimum permission: observer

Namespace tools

shellhub_list_namespaces

List namespaces accessible to the authenticated user.

Input Type Required Description
page integer no Page (default: 1)
per_page integer no Per page (default: 20)

Minimum permission: any authenticated user

Output: { "total": N, "namespaces": [...] }

shellhub_get_namespace

Get namespace details including settings (session recording, device auto-accept, etc.).

Input Type Required Description
tenant_id string yes Namespace tenant ID (UUID)

Minimum permission: any authenticated user

OAuth flow in detail

  1. Discovery — MCP client fetches GET /.well-known/oauth-authorization-server to find endpoints.
  2. PKCE setup — client generates code_verifier (random 32+ bytes, base64url) and code_challenge = base64url(sha256(code_verifier)).
  3. Authorize — client redirects browser to GET /api/oauth/authorize?client_id=...&redirect_uri=...&code_challenge=...&code_challenge_method=S256&state=....
  4. Login — ShellHub API redirects to /login?oauth_client_id=.... User logs in via normal ShellHub UI.
  5. Callback — UI posts to POST /api/oauth/authorize/callback with the auth context. API issues a short-lived code (5 min, stored in Redis).
  6. Token exchange — client posts to POST /api/oauth/token with grant_type=authorization_code, code, code_verifier, client_id, redirect_uri. API verifies PKCE, deletes the code, returns a ShellHub JWT.
  7. Use — client sends Authorization: Bearer <jwt> on every MCP request.

Managing OAuth clients

# List clients in your namespace
curl https://<host>/api/oauth/clients -H "Authorization: Bearer <token>"

# Delete a client
curl -X DELETE https://<host>/api/oauth/clients/<id> -H "Authorization: Bearer <token>"

Troubleshooting

401 on /mcp — token missing or expired. Re-authenticate via OAuth flow.

forbidden: insufficient permissions — your role lacks the required permission. Check your role in the namespace settings.

device is offline — device is not connected to ShellHub. Check device agent status.

OAuth client not foundclient_id mismatch or client was deleted. Re-register the client.

redirect_uri not allowed — the redirect_uri in the authorize request must exactly match one registered for the client.

Test plan

  • POST /api/oauth/register returns a valid client_id/client_secret for unauthenticated dynamic registration
  • GET /.well-known/oauth-authorization-server returns the issuer metadata
  • Full PKCE flow from MCP Inspector: authorize → login in browser → callback → token exchange → JWT
  • POST /mcp with invalid token returns tool error unauthorized
  • POST /mcp with valid token responds with the MCP capabilities and tool list
  • Calling shellhub_list_devices, shellhub_get_namespace, etc. returns expected data
  • Permission errors surface as forbidden: insufficient permissions for under-privileged roles
  • Migrations apply cleanly on Postgres and MongoDB

@danielgatis danielgatis requested review from a team as code owners April 30, 2026 22:33
@danielgatis danielgatis marked this pull request as draft April 30, 2026 22:35
@danielgatis danielgatis marked this pull request as ready for review April 30, 2026 22:46
@otavio
Copy link
Copy Markdown
Member

otavio commented Apr 30, 2026

/review

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 30, 2026

Claude finished @otavio's task in 13m 10s —— View job


Code Review Complete

Reviewed 25 files. 5 inline issues posted (2 critical, 3 high).

# Severity File Issue
1 critical api/routes/oauth.go:271 Unauthenticated OAuthDeleteClient — no auth check, nginx bypasses auth_request
2 critical gateway/nginx/conf.d/shellhub.conf:153 X-Tenant-ID header forgery — /api/oauth/* passes client-supplied headers unstripped
3 high api/routes/oauth.go:90 Query parameter injection — redirectURI and state not URL-encoded in login redirect
4 high api/services/oauth.go:161 PKCE verification uses strings.EqualFold (case-insensitive) instead of exact comparison
5 high ui-react/.../Login.tsx:112 useEffect infinite retry loop when OAuth callback fails

Additional notes

Testing gaps: OAuthExchangeCode (the security-critical PKCE + code exchange function) and OAuthCallback (the code issuance endpoint) have zero test coverage. OAuthDynamicRegister is also untested. The OAuthCreateAuthCode test uses NullCache which silently discards cache operations, so it doesn't verify the code is actually stored.

Cross-repo impact: The cloud/pkg/store/mock_store.go mock will need regeneration to include the new OAuthClientStore methods, otherwise cloud tests with -tags mocks will fail to compile.

mcp-go dependency: github.com/mark3labs/mcp-go is marked // indirect in api/go.mod but is directly imported in api/routes/mcp.go. Running go mod tidy would correct the classification.


If you've addressed the feedback and want a new review, tag @shellhub-io/admin and a team member can trigger it.

Comment thread api/routes/oauth.go
Comment thread gateway/nginx/conf.d/shellhub.conf
Comment thread api/routes/oauth.go Outdated
Comment thread api/services/oauth.go
Comment thread ui-react/apps/console/src/pages/Login.tsx
@danielgatis danielgatis force-pushed the feat/mcp-server branch 2 times, most recently from 0291196 to 401679b Compare April 30, 2026 23:24
@otavio
Copy link
Copy Markdown
Member

otavio commented May 1, 2026

/review

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 1, 2026

Claude finished @otavio's task in 4m 1s —— View job


Code Review Complete

Reviewed 25 files. 3 inline issues posted (2 critical, 1 high).

# Severity File Issue
1 critical api/routes/mcp.go:179 Cross-tenant IDOR — shellhub_get_device and shellhub_get_session bypass namespace scoping because gateway.TenantFromContext returns nil in MCP context
2 critical api/routes/mcp.go:421 Arbitrary namespace enumeration — shellhub_get_namespace accepts user-supplied tenant_id with no membership check
3 high api/services/oauth.go:152 Authorization code consumed before token issuance — if CreateUserToken fails, user must restart the entire OAuth flow

Additional notes

Cross-repo impact: Adding OAuthClientStore to the Store interface means cloud/pkg/store/mock_store.go will need regeneration — otherwise cloud tests with -tags mocks will fail to compile.

Testing gap: OAuthExchangeCode (the security-critical PKCE + code exchange function) has no happy-path test coverage at the service layer. The route-level test mocks OAuthExchangeCode entirely, so the auth code ordering issue (Finding 3) and code consumption logic are untested.


If you've addressed the feedback and want a new review, tag @shellhub-io/admin and a team member can trigger it.

Comment thread api/services/oauth_test.go
Comment thread api/services/oauth_test.go
Comment thread api/services/oauth_test.go
Comment thread api/routes/mcp.go
Comment thread api/routes/mcp.go
Comment thread api/services/oauth.go
@otavio
Copy link
Copy Markdown
Member

otavio commented May 1, 2026

/review

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 1, 2026

Claude finished @otavio's task in 3m 15s —— View job


Code Review Complete

Reviewed 26 files. 5 inline issues posted (3 high, 1 high, 1 medium).

# Severity File Issue
1 high api/store/mongo/migrations/migration_121.go:42 MongoDB unique index missing PartialFilterExpression — breaks dynamic client registration after first use
2 high api/routes/mcp.go:153 Missing Paginator.Normalize() — MCP list tools allow unbounded result sets (up to millions of rows)
3 high api/services/oauth.go:129 TOCTOU race in auth code consumption — concurrent requests can redeem the same code twice
4 high api/routes/oauth.go:192 OAuthDynamicRegister accepts arbitrary redirect URIs without URL validation (unlike OAuthRegisterClient)
5 medium api/routes/oauth.go:174 Internal error details leaked via err.Error() in token error response

Additional notes

Cross-repo impact: cloud/pkg/store/mock_store.go has 0 OAuthClient methods — cloud tests with -tags mocks will fail to compile after this merges. This was noted in previous reviews and remains unaddressed.

Testing gap: The 11 MCP tool handlers in api/routes/mcp.go (444 lines) have no test coverage at any level. The auth checks, permission gates, and tenant scoping in these handlers are unverified by tests.


If you've addressed the feedback and want a new review, tag @shellhub-io/admin and a team member can trigger it.

Comment thread api/store/mongo/migrations/migration_121.go
Comment thread api/routes/mcp.go
Comment thread api/services/oauth.go
Comment thread api/routes/oauth.go
Comment thread api/routes/oauth.go
Implements OAuth 2.1 with PKCE, dynamic client registration (RFC 7591),
authorization server metadata (RFC 8414), and exposes a Streamable HTTP
MCP server at /mcp with device, session, and namespace tools.
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.

2 participants