Skip to content
Closed
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
54 changes: 51 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ Configure the server in the client:
}
```

4. Ask about your clusters:
![Example prompt asking about a cluster](images/cluster-prompt-example.png)

### Providing the Offline Token via Request Header

If you do not set the `OFFLINE_TOKEN` environment variable, you can provide the token as a request header.
Expand All @@ -69,9 +72,6 @@ When configuring your MCP client, add the `OCM-Offline-Token` header:
}
```

4. Ask about your clusters:
![Example prompt asking about a cluster](images/cluster-prompt-example.png)

## Available Tools

The MCP server provides the following tools for interacting with the OpenShift Assisted Installer:
Expand Down Expand Up @@ -135,3 +135,51 @@ The MCP server provides the following tools for interacting with the OpenShift A
* **Create a cluster**: "Create a new cluster named 'my-cluster' with OpenShift 4.14 and base domain 'example.com'"
* **Check cluster events**: "What events happened on cluster abc123?"
* **Install a cluster**: "Start the installation for cluster abc123"

## Authorization Options

The server supports multiple authorization methods for accessing the Assisted Installer API. The
method used depends on the environment variables and headers you provide. The following methods are
checked in order of priority; the first one that succeeds will be used, and the rest will be
ignored:

### 1. Access token in the `Authorization` request header

If the `Authorization` request header contains a bearer token, it will be passed directly to the
Assisted Installer API. In this case, the OAuth flow will not be triggered, and any values provided
in the `OFFLINE_TOKEN` environment variable or the `OCM-Offline-Token` request header will be
ignored.

### 2. OAuth flow

If the `OAUTH_ENABLED` environment variable is set to `true`, the server will use a subset of the
OAuth protocol that MCP clients (such as the one in VS Code) use for authentication. When you
attempt to connect, the MCP client will open a browser window where you can enter your credentials.
The client will then request an access token, which the server will use to authenticate requests to
the Assisted Installer API.

When using this authentication method, the `OFFLINE_TOKEN` environment variable and the
`OCM-Offline-Token` header will be ignored.

You can configure the OAuth authorization server and client identifier using the `OAUTH_URL` and
`OAUTH_CLIENT` environment variables. The default values are:

- `OAUTH_URL`: `https://sso.redhat.com/auth/realms/redhat-external`
- `OAUTH_CLIENT`: `cloud-services`

The `SELF_URL` environment variable specifies the base URL that the server uses to construct URLs
referencing itself. For example, when OAuth is enabled, the server will generate the dynamic client
registration URL by appending `/oauth/register` to this base URL. The default value is
`http://localhost:8000`, but in production environments, it should be set to the actual URL of the
server as accessible to clients. For instance, if the server is accessed through a reverse proxy
using HTTPS and the host `my.host.com`, the value should be set to `https://my.host.com`.

### 3. Offline token via environment variable

If you set the `OFFLINE_TOKEN` environment variable, the server will use this offline token to
request an access token, which will then be used to call the Assisted Installer API.

### 4. Offline token via request header

If the `OCM-Offline-Token` request header is set, the server will use it to request an access token,
and will then use that access token to call the Assisted Installer API.
140 changes: 140 additions & 0 deletions oauth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""
This module contains the Starlette middleware that implemnts OAuth authorization.
"""

import httpx
import starlette.middleware
import starlette.middleware.base
import starlette.requests
import starlette.responses
import starlette.types


class Middleware(starlette.middleware.base.BaseHTTPMiddleware):
"""
This middleware implements the OAuth metadata and registration endpoints that MCP clients
will try to use when the server responds with a 401 status code.
"""

def __init__(
self,
app: starlette.types.ASGIApp,
self_url: str,
oauth_url: str,
oauth_client: str,
):
"""
Creates a new OAuth middleware.

Args:
app (starlette.types.ASGIApp): The starlette application.
self_url (str): Base URL of the service, as seen by clients.
oauth_url (str): Base URL of the authorization server.
oauth_client (str): The client identifier.
"""
super().__init__(app=app)
self._self_url = self_url
self._oauth_url = oauth_url
self._oauth_client = oauth_client

async def dispatch(
self,
request: starlette.requests.Request,
call_next: starlette.middleware.base.RequestResponseEndpoint,
) -> starlette.responses.Response:
"""
Dispatches the request, calling the OAuth handlers or else the protected application.
"""
# The OAuth endpoints don't require authentication:
method = request.method
path = request.url.path
if method == "GET" and path == "/.well-known/oauth-protected-resource":
return await self._resource(request)
if method == "GET" and path == "/.well-known/oauth-authorization-server":
return await self._metadata(request)
if method == "POST" and path == "/oauth/register":
return await self._register(request)

# The rest of the endpoints do require authentication. Note that we are not validating the
# bearer token, just requiring the authorization header, so that the client will receive
# the 401 response code and trigger the OAuth flow.
auth = request.headers.get("authorization")
if auth is None:
resource_url = f"{self._self_url}/.well-known/oauth-protected-resource"
return starlette.responses.Response(
status_code=401,
headers={
"WWW-Authenticate": f"Bearer resource_metadata=\"{resource_url}\"",
},
)

return await call_next(request)

async def _resource(self, request: starlette.requests.Request) -> starlette.responses.Response:
"""
This method implements the OAuth protected resource endpoint.
"""
return starlette.responses.JSONResponse(
content={
"resource": self._self_url,
"authorization_servers": [
self._self_url,
],
"bearer_methods_supported": [
"header",
],
"scopes_supported": [
"openid",
"api.ocm",
],
}
)

async def _metadata(self, request: starlette.requests.Request) -> starlette.responses.Response:
"""
This method implements the OAuth metadata endpoint. It gets the metadata from our real authorization
server, and replaces a few things that are needed to satisfy MCP clients.
"""
# Get the metadata from the real authorization service:
try:
async with httpx.AsyncClient() as client:
response = await client.get(
url=f"{self._oauth_url}/.well-known/oauth-authorization-server",
timeout=10,
)
response.raise_for_status()
body = response.json()
except (httpx.RequestError, httpx.HTTPStatusError):
return starlette.responses.Response(status_code=503)

# The MCP clients will want to dynamically register the client, but we don't want that because our
# authorization server doesn't allow us to do it. So we replace the registration endpoint with our
# own, where we can return a fake response to make the MCP clients happy.
body["registration_endpoint"] = f"{self._self_url}/oauth/register"

# The MCP clients also try to request all the scopes listed in the metadata, but our authorization
# server returns a lot of scopes, and most of them will be rejected for our client. So we replace
# that large list with a much smaller list containing only the scopes that we need.
body["scopes_supported"] = [
"openid",
"api.ocm",
]

# Return the modified metadata:
return starlette.responses.JSONResponse(
content=body,
)

async def _register(self, request: starlette.requests.Request) -> starlette.responses.Response:
"""
This method implements the OAuth dynamic client registration endpoint. It responds to all requests
with a fixed client identifier.
"""
body = await request.json()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling for JSON parsing.

The await request.json() call can raise exceptions if the request body is malformed JSON or missing. This should be handled gracefully.

-    body = await request.json()
+    try:
+        body = await request.json()
+    except Exception:
+        return starlette.responses.JSONResponse(
+            status_code=400,
+            content={"error": "invalid_request", "error_description": "Invalid JSON in request body"},
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
body = await request.json()
try:
body = await request.json()
except Exception:
return starlette.responses.JSONResponse(
status_code=400,
content={"error": "invalid_request", "error_description": "Invalid JSON in request body"},
)
🤖 Prompt for AI Agents
In oauth.py at line 129, the call to await request.json() can raise exceptions
if the JSON is malformed or missing. Wrap this call in a try-except block to
catch JSON parsing errors, and handle them gracefully by returning an
appropriate error response or message instead of letting the exception
propagate.

redirect_uris = body.get("redirect_uris", [])
return starlette.responses.JSONResponse(
content={
"client_id": self._oauth_client,
"redirect_uris": redirect_uris,
},
)
53 changes: 42 additions & 11 deletions server.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
from mcp.server.fastmcp import FastMCP
import json
import os

import fastmcp
import fastmcp.server.dependencies
import requests
import uvicorn

import oauth

from service_client import InventoryClient

mcp = FastMCP("AssistedService", host="0.0.0.0")
mcp = fastmcp.FastMCP("AssistedService")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? I think the default is 127.0.0.1

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default may be 127.0.0.1, but it isn't relevant anymore because we don't run the MCP directly. Instead we create a Starlette application and start it with this:

uvicorn.run(app, host="0.0.0.0", port=8000)

See the __main__ code for details.


def get_offline_token() -> str:
"""Retrieve the offline token from environment variables or request headers.
Expand All @@ -26,7 +31,8 @@ def get_offline_token() -> str:
if token:
return token

token = mcp.get_context().request_context.request.headers.get("OCM-Offline-Token")
headers = fastmcp.server.dependencies.get_http_headers()
token = headers.get("ocm-offline-token")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why change this? Also I assume this header name is not case sensitive?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As part of the change we are importing fastmcp instead of mcp.server.fastmcp. One of the differences is that there is no get_context() method. Instead of that there is the fastmcp.server.dependencies package that provides methods (like get_http_headers) to get the dependencies.

Changing the imported package was necessary because the mcp.server.fastmcp doesn't expose the functionality to create a Starlette applicaton from the FastMCP instance, and I needed that to be able to add the middleware.

The get_http_headers method returns a plain Python dictionary witht the headers converted to lower case:

https://github.com/jlowin/fastmcp/blob/edbf0ae81625156891a76a437304fa70ea2dd947/src/fastmcp/server/dependencies.py#L79-L89

For more information, this is what Gemini has to say about the differences between fastmcp and mcp.server.fastmcp:

The relationship between "fastmcp" and "mcp.server.fastmcp" in Python is a matter of packaging and evolution within the Model Context Protocol (MCP) ecosystem.

Here's the breakdown:

  • fastmcp (the standalone library):

    • This refers to the fastmcp Python package that you would install directly (e.g., pip install fastmcp).
    • It's a high-level, Pythonic framework designed to dramatically simplify the process of building MCP servers.
    • It abstracts away many of the complexities of the raw MCP protocol, offering a decorator-based API (@mcp.tool(), @mcp.resource(), @mcp.prompt()) that makes it easy to define functions that expose capabilities to Large Language Models (LLMs).
    • It aims to provide a fast and simple way for Python developers to create MCP servers.
  • mcp.server.fastmcp (part of the official MCP Python SDK):

    • This refers to fastmcp as it is integrated within the official modelcontextprotocol/python-sdk.
    • Crucially, FastMCP 1.0 was so successful that it was directly incorporated into the official MCP Python SDK. This means that for basic use cases, you can often use mcp.server.fastmcp directly if you have the SDK installed.
    • It represents the "upstream" version of FastMCP for those who are using the broader MCP Python SDK.

In essence:

  • fastmcp is the project/framework that originated as a simpler way to build MCP servers.
  • mcp.server.fastmcp is the module within the official modelcontextprotocol SDK that contains the FastMCP implementation.

Why the two ways of importing/using?

  • Historical Context: FastMCP was initially a separate project that proved to be very effective. The developers of the official MCP Python SDK recognized its value and integrated it.
  • Version Evolution: While mcp.server.fastmcp in the SDK might represent a stable version (often FastMCP 1.0), the standalone fastmcp package continues to evolve with newer versions (e.g., FastMCP 2.0 and beyond) that introduce advanced features like proxying, composing servers, and generating servers from OpenAPI specs.
  • Choice for Developers:
    • If you're starting a new project and want the latest features and a more direct experience with the FastMCP framework, you'd likely install fastmcp as a standalone package.
    • If you're already using the broader MCP Python SDK and only need basic MCP server functionalities, the mcp.server.fastmcp module within the SDK might suffice.

General Recommendation:

For new projects, it's generally recommended to install and use the standalone fastmcp package, as it is the actively maintained version and provides the most up-to-date features for building MCP servers.

if token:
return token

Expand All @@ -46,13 +52,12 @@ def get_access_token() -> str:
RuntimeError: If it isn't possible to obtain or generate the access token.
"""
# First try to get the token from the authorization header:
request = mcp.get_context().request_context.request
if request is not None:
header = request.headers.get("Authorization")
if header is not None:
parts = header.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1]
headers = fastmcp.server.dependencies.get_http_headers()
header = headers.get("authorization")
if header is not None:
parts = header.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1]

# Now try to get the offline token, and generate a new access token from it:
params = {
Expand Down Expand Up @@ -300,4 +305,30 @@ def set_host_role(host_id: str, infraenv_id: str, role: str) -> str:
return InventoryClient(get_access_token()).update_host(host_id, infraenv_id, host_role=role).to_str()

if __name__ == "__main__":
mcp.run(transport="sse")
# We create a Starlette application so that we can add middleware:
app = mcp.http_app(transport="sse")

# Add the OAuth middleware if enabled:
oauth_enabled = os.getenv("OAUTH_ENABLED", "false").lower() == "true"
if oauth_enabled:
self_url = os.getenv(
"SELF_URL",
"http://localhost:8000",
)
oauth_url = os.getenv(
"OAUTH_URL",
"https://sso.redhat.com/auth/realms/redhat-external",
)
oauth_client = os.getenv(
"OAUTH_CLIENT",
"cloud-services",
)
app.add_middleware(
oauth.Middleware,
self_url=self_url,
oauth_url=oauth_url,
oauth_client=oauth_client,
)

# Start the application
uvicorn.run(app, host="0.0.0.0", port=8000)