Skip to content

Commit 533e00f

Browse files
DrFaust92claude
andcommitted
feat: add FastMCP OAuthProxy support for running as a remote MCP server
Adds support for running okta-mcp-server as a remote HTTP server using FastMCP's OAuthProxy, enabling browser-based Okta SSO authentication instead of device auth flow. This allows deployment as a shared service (e.g., in Docker/Kubernetes) where users authenticate via browser redirect. Changes: - server.py: Add OAuthProxy with OktaIntrospectionVerifier for streamable-http transport mode - client.py: Dual-mode auth — keyring for stdio, Bearer token from MCP auth context for HTTP mode - Dockerfile: Default to streamable-http transport, expose port 8000 - .env.example: Document new env vars (MCP_TRANSPORT, MCP_SERVER_URL, OKTA_CLIENT_SECRET) - pyproject.toml: Switch from mcp[cli] to fastmcp>=3.0.0, add httpx Closes #13 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 104c46d commit 533e00f

File tree

5 files changed

+140
-26
lines changed

5 files changed

+140
-26
lines changed

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ OKTA_ORG_URL=
77
# Required: OAuth Client ID from your Okta application
88
OKTA_CLIENT_ID=
99

10+
# Required (HTTP mode only): OAuth Client Secret from your Okta application
11+
# OKTA_CLIENT_SECRET=
12+
1013
# Required: API Scopes (space-separated, e.g., "okta.users.read okta.groups.manage")
1114
OKTA_SCOPES=okta.users.read okta.groups.read
1215

@@ -15,6 +18,13 @@ OKTA_SCOPES=okta.users.read okta.groups.read
1518
# OKTA_PRIVATE_KEY=
1619
# OKTA_KEY_ID=
1720

21+
# Optional: Transport mode ("stdio" for CLI, "streamable-http" for remote/Docker)
22+
# MCP_TRANSPORT=streamable-http
23+
24+
# Optional: Public URL of the MCP server (required when MCP_TRANSPORT=streamable-http)
25+
# Used for OAuth redirect URIs. Must match the Okta app's redirect URI setting.
26+
# MCP_SERVER_URL=https://mcp.example.com
27+
1828
# Optional: Logging configuration
1929
# OKTA_LOG_LEVEL=DEBUG
2030
# OKTA_LOG_FILE=/app/logs/okta-mcp.log

Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ ENV PATH="/app/.venv/bin:$PATH"
3232
ENV PYTHONUNBUFFERED=1
3333
# Use file-based keyring backend for Docker (no system keyring available)
3434
ENV PYTHON_KEYRING_BACKEND=keyrings.alt.file.PlaintextKeyring
35+
# Use streamable HTTP transport in Docker
36+
ENV MCP_TRANSPORT=streamable-http
37+
38+
EXPOSE 8000
3539

3640
# Run the server using the console script entry point
3741
ENTRYPOINT ["okta-mcp-server"]

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ readme = "README.md"
66
requires-python = ">=3.13"
77
dependencies = [
88
"loguru>=0.7.3",
9-
"mcp[cli]>=1.26.0,<2.0.0",
9+
"fastmcp>=3.0.0",
1010
"okta>=2.9.13",
1111
"requests>=2.32.4",
1212
"ruff>=0.11.13",
1313
"keyring>=25.6.0",
1414
"keyrings.alt>=5.0.0",
1515
"flatdict>=4.1.0",
16+
"httpx>=0.27.0",
1617
]
1718

1819
[build-system]

src/okta_mcp_server/server.py

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,44 +5,113 @@
55
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
66
# See the License for the specific language governing permissions and limitations under the License.
77

8+
from __future__ import annotations
9+
810
import os
911
import sys
1012
from collections.abc import AsyncIterator
1113
from contextlib import asynccontextmanager
1214
from dataclasses import dataclass
1315

16+
from fastmcp import FastMCP
1417
from loguru import logger
15-
from mcp.server.fastmcp import FastMCP
1618

1719
from okta_mcp_server.utils.auth.auth_manager import OktaAuthManager
1820

1921
LOG_FILE = os.environ.get("OKTA_LOG_FILE")
22+
MCP_TRANSPORT = os.environ.get("MCP_TRANSPORT", "stdio")
2023

2124

2225
@dataclass
2326
class OktaAppContext:
24-
okta_auth_manager: OktaAuthManager
27+
okta_auth_manager: OktaAuthManager | None = None
2528

2629

2730
@asynccontextmanager
2831
async def okta_authorisation_flow(server: FastMCP) -> AsyncIterator[OktaAppContext]:
2932
"""
30-
Manages the application lifecycle. It initializes the OktaManager on startup,
31-
performs authorization, and yields the context for use in tools.
33+
Manages the application lifecycle. In stdio mode, initializes OktaAuthManager
34+
for device/JWT flow. In HTTP mode with OAuthProxy, authentication is handled
35+
via browser redirect — no OktaAuthManager needed.
3236
"""
33-
logger.info("Starting Okta authorization flow")
34-
manager = OktaAuthManager()
35-
await manager.authenticate()
36-
logger.info("Okta authentication completed successfully")
37-
38-
try:
39-
yield OktaAppContext(okta_auth_manager=manager)
40-
finally:
41-
logger.debug("Clearing Okta tokens")
42-
manager.clear_tokens()
43-
37+
if MCP_TRANSPORT == "streamable-http":
38+
logger.info("HTTP transport: OAuthProxy handles authentication via browser redirect")
39+
yield OktaAppContext()
40+
else:
41+
logger.info("Initializing OktaAuthManager (authentication deferred to first tool call)")
42+
manager = OktaAuthManager()
43+
try:
44+
yield OktaAppContext(okta_auth_manager=manager)
45+
finally:
46+
logger.debug("Clearing Okta tokens")
47+
manager.clear_tokens()
48+
49+
50+
# --- Build the FastMCP instance based on transport mode ---
51+
52+
if MCP_TRANSPORT == "streamable-http":
53+
import httpx
54+
from fastmcp.server.auth import AccessToken, OAuthProxy, TokenVerifier
55+
56+
class OktaIntrospectionVerifier(TokenVerifier):
57+
"""Validates opaque Okta tokens via the introspection endpoint."""
58+
59+
def __init__(self, introspect_url: str, client_id: str, client_secret: str):
60+
super().__init__()
61+
self._introspect_url = introspect_url
62+
self._client_id = client_id
63+
self._client_secret = client_secret
64+
65+
async def verify_token(self, token: str) -> AccessToken | None:
66+
async with httpx.AsyncClient() as client:
67+
resp = await client.post(
68+
self._introspect_url,
69+
data={"token": token, "token_type_hint": "access_token"},
70+
auth=(self._client_id, self._client_secret),
71+
)
72+
if resp.status_code != 200:
73+
return None
74+
data = resp.json()
75+
if not data.get("active"):
76+
return None
77+
return AccessToken(
78+
token=token,
79+
client_id=data.get("client_id", self._client_id),
80+
scopes=data.get("scope", "").split(),
81+
expires_at=data.get("exp"),
82+
)
83+
84+
_mcp_server_url = os.environ.get("MCP_SERVER_URL", "http://localhost:8000")
85+
_okta_org_url = os.environ.get("OKTA_ORG_URL", "").rstrip("/")
86+
_okta_client_id = os.environ.get("OKTA_CLIENT_ID", "")
87+
_okta_client_secret = os.environ.get("OKTA_CLIENT_SECRET", "")
88+
_okta_scopes = os.environ.get("OKTA_SCOPES", "openid profile email offline_access")
89+
90+
_auth = OAuthProxy(
91+
upstream_authorization_endpoint=f"{_okta_org_url}/oauth2/v1/authorize",
92+
upstream_token_endpoint=f"{_okta_org_url}/oauth2/v1/token",
93+
upstream_client_id=_okta_client_id,
94+
upstream_client_secret=_okta_client_secret,
95+
token_verifier=OktaIntrospectionVerifier(
96+
introspect_url=f"{_okta_org_url}/oauth2/v1/introspect",
97+
client_id=_okta_client_id,
98+
client_secret=_okta_client_secret,
99+
),
100+
base_url=_mcp_server_url,
101+
require_authorization_consent=False,
102+
extra_authorize_params={"scope": _okta_scopes},
103+
)
44104

45-
mcp = FastMCP("Okta IDaaS MCP Server", lifespan=okta_authorisation_flow)
105+
mcp = FastMCP(
106+
"Okta IDaaS MCP Server",
107+
lifespan=okta_authorisation_flow,
108+
auth=_auth,
109+
)
110+
else:
111+
mcp = FastMCP(
112+
"Okta IDaaS MCP Server",
113+
lifespan=okta_authorisation_flow,
114+
)
46115

47116

48117
def main():
@@ -70,4 +139,7 @@ def main():
70139
from okta_mcp_server.tools.system_logs import system_logs # noqa: F401
71140
from okta_mcp_server.tools.users import users # noqa: F401
72141

73-
mcp.run()
142+
if MCP_TRANSPORT == "streamable-http":
143+
mcp.run(transport=MCP_TRANSPORT, host="0.0.0.0", port=8000)
144+
else:
145+
mcp.run(transport=MCP_TRANSPORT)

src/okta_mcp_server/utils/client.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,53 @@
55
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
66
# See the License for the specific language governing permissions and limitations under the License.
77

8+
from __future__ import annotations
9+
10+
import os
11+
812
import keyring
913
from loguru import logger
1014
from okta.client import Client as OktaClient
1115

1216
from okta_mcp_server.utils.auth.auth_manager import SERVICE_NAME, OktaAuthManager
1317

1418

15-
async def get_okta_client(manager: OktaAuthManager) -> OktaClient:
16-
"""Initialize and return an Okta client"""
19+
async def get_okta_client(manager: OktaAuthManager | None) -> OktaClient:
20+
"""Initialize and return an Okta client.
21+
22+
In stdio mode (manager is not None): uses OktaAuthManager + keyring.
23+
In HTTP mode (manager is None): gets Okta token from MCP Bearer auth context.
24+
"""
1725
logger.debug("Initializing Okta client")
18-
api_token = keyring.get_password(SERVICE_NAME, "api_token")
19-
if not await manager.is_valid_token():
20-
logger.warning("Token is invalid or expired, re-authenticating")
21-
await manager.authenticate()
26+
27+
if manager is not None:
28+
# stdio mode — existing behavior
2229
api_token = keyring.get_password(SERVICE_NAME, "api_token")
30+
if not await manager.is_valid_token():
31+
logger.warning("Token is invalid or expired, re-authenticating")
32+
await manager.authenticate()
33+
api_token = keyring.get_password(SERVICE_NAME, "api_token")
34+
org_url = manager.org_url
35+
else:
36+
# HTTP mode — OAuthProxy passes through the upstream Okta access token.
37+
# The Bearer token in the request IS the Okta access token.
38+
from mcp.server.auth.middleware.auth_context import get_access_token
39+
40+
access_token_info = get_access_token()
41+
if access_token_info is None:
42+
raise RuntimeError("No authenticated user in HTTP mode")
43+
44+
api_token = access_token_info.token
45+
if not api_token:
46+
raise RuntimeError("No Okta access token in auth context")
47+
48+
org_url = os.environ.get("OKTA_ORG_URL", "")
49+
2350
config = {
24-
"orgUrl": manager.org_url,
51+
"orgUrl": org_url,
2552
"token": api_token,
2653
"authorizationMode": "Bearer",
2754
"userAgent": "okta-mcp-server/0.0.1",
2855
}
29-
logger.debug(f"Okta client configured for org: {manager.org_url}")
56+
logger.debug(f"Okta client configured for org: {org_url}")
3057
return OktaClient(config)

0 commit comments

Comments
 (0)