Factry Historian MCP Server (HTTP mode)
This guide walks you through running the Factry Historian MCP Server in HTTP mode. Use this when you have a custom LLM application or a web-based AI platform that connects to MCP servers over HTTP rather than launching them as a local process.
If you are using Claude Desktop or a similar desktop AI client, the standard Docker guide is simpler and recommended instead.
When to use HTTP mode
Scenario | Recommended transport |
|---|---|
Claude Desktop, Cursor, VS Code Copilot | stdio (see standard guide) |
Custom LLM application or internal AI platform | HTTP |
Web-based AI tools that connect to remote MCP servers | HTTP |
Multiple users or clients sharing one MCP server | HTTP |
Prerequisites
Before you start, make sure you have:
A running Factry Historian instance
- The API URL of your Historian instance (e.g. https://historian.example.com/api)
- An API token with access to Factry Historian. Obtaining an API token
- Your Organization UUID, visible in the Historian web interface under organization settings. OrganizationsOrganizations
Docker installed on the server that will run the MCP server. Install from the official Docker website:
- Mac / Windows: Docker Desktop
- Linux: Docker Engine install guide
A domain name and TLS certificate if you intend to expose the server to an external LLM platform (required for production, see Exposing over HTTPS).
Basic setup (no authentication)
This is the quickest way to get the server running. It is suitable for testing on a private network, or when your LLM app and the MCP server are on the same machine. Do not expose this setup to the internet without adding authentication.
Start the container:
docker run -d \
--name historian-mcp \
-p 8080:8080 \
-e FACTRY_HISTORIAN_API_URL=https://historian.example.com/api \
-e FACTRY_HISTORIAN_API_TOKEN=your-historian-token \
-e FACTRY_HISTORIAN_ORGANIZATION_UUID=your-org-uuid \
-e TRANSPORT_MODE=http \
-e HTTP_HOST=0.0.0.0 \
-e HTTP_PORT=8080 \
factry/mcp-server:latestThe server is now reachable at http://your-server:8080.
Verify it is running
curl http://localhost:8080/healthYou should receive a 200 OK response with a JSON body like {"status":"healthy","time":"..."}.
Test with MCP Inspector
The MCP Inspector is a tool for testing MCP servers. Install and launch it with:
npx @modelcontextprotocol/inspectorThen open the Inspector UI in your browser and:
- Select StreamableHttp as the transport type
- Enter URL: http://localhost:8080
- Click Connect
- Open the Tools tab and try calling search-assets
Exposing over HTTPS
Most LLM platforms require the MCP server to be reachable over HTTPS on port 443. Even if your platform accepts a custom port, using HTTPS is strongly recommended to protect data in transit.
Run the MCP server on localhost and put a reverse proxy in front of it. The proxy handles TLS and forwards traffic to the container.
Bind the container port to localhost only
When a reverse proxy handles all external traffic, there is no reason for the container's HTTP port to be reachable from outside the host. Use 127.0.0.1:8080:8080 instead of 8080:8080 in your docker run command to bind the port to localhost only:
docker run -d \
--name historian-mcp \
-p 127.0.0.1:8080:8080 \
...
factry/mcp-server:latestThis ensures the unauthenticated HTTP port is never directly reachable from the network, only the reverse proxy can reach it.
With Caddy (recommended, automatic TLS)
Install Caddy and create a Caddyfile:
mcp.your-domain.com {
reverse_proxy localhost:8080
}Caddy automatically obtains and renews a Let's Encrypt certificate. Start it with:
caddy runWith nginx
server {
listen 443 ssl;
server_name mcp.your-domain.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
location / {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Connection '';
proxy_buffering off;
}
}The proxy_buffering off line is important, it ensures Server-Sent Events (SSE) are streamed in real time rather than buffered.
After setting up the reverse proxy, your MCP server is reachable at https://mcp.your-domain.com.
Keep HTTP_HOST=0.0.0.0 in the container if the reverse proxy is on the same host. If the proxy is on a different machine, make sure the port is reachable from that machine and not exposed to the wider internet directly.
Authentication
When the MCP server is accessible over a network, you should enable authentication so that only authorized users and applications can use it.
The server supports JWT authentication, which is compatible with any standard OAuth 2.0 / OpenID Connect provider, including Azure AD, Okta, Auth0, and Keycloak.
How it works:
- Your LLM app obtains a JWT access token from your identity provider (it likely already does this as part of user login)
- Your app passes the token in every MCP request as Authorization: Bearer <token>
- The MCP server validates the token's signature and expiration, and (optionally) checks for required scopes
Option A: JWKS validation (recommended for Azure AD and other OAuth providers)
Most identity providers sign tokens with a private key and publish the corresponding public keys at a JWKS endpoint. This is the most secure option and handles key rotation automatically.
Add these variables to your docker run command:
-e AUTH_PROVIDER=jwt \
-e JWKS_URL=https://your-identity-provider.com/.well-known/jwks.jsonCommon JWKS URLs:
Provider | JWKS URL |
|---|---|
Azure AD (Entra ID) | https://login.microsoftonline.com/{tenant-id}/discovery/v2.0/keys |
Azure AD FS (on-premises) | https://your-adfs.example.com/adfs/discovery/keys |
Okta | https://your-domain.okta.com/oauth2/default/v1/keys |
Auth0 | https://your-domain.auth0.com/.well-known/jwks.json |
Keycloak | https://your-keycloak.com/realms/your-realm/protocol/openid-connect/certs |
Replace {tenant-id} with your Azure AD Directory (tenant) ID, visible in the Azure portal under Azure Active Directory > Overview.
Full example for Azure AD:
docker run -d \
--name historian-mcp \
-p 8080:8080 \
-e FACTRY_HISTORIAN_API_URL=https://historian.example.com/api \
-e FACTRY_HISTORIAN_API_TOKEN=your-historian-token \
-e FACTRY_HISTORIAN_ORGANIZATION_UUID=your-org-uuid \
-e TRANSPORT_MODE=http \
-e HTTP_HOST=0.0.0.0 \
-e HTTP_PORT=8080 \
-e AUTH_PROVIDER=jwt \
-e JWKS_URL=https://login.microsoftonline.com/{tenant-id}/discovery/v2.0/keys \
-e REQUIRED_SCOPES=api://your-app-id/historian.read \
factry/mcp-server:latestREQUIRED_SCOPES is optional but recommended, it ensures only tokens issued for the MCP server are accepted. Define a scope in your identity provider's app registration (e.g. historian.read) and pass its full URI here.
Audience validation (strongly recommended)
Without audience validation, any token your identity provider issues for any application in the same tenant is accepted as long as the signature is valid. Set EXPECTED_AUDIENCE to the identifiers your tokens will carry:
-e EXPECTED_AUDIENCE=api://your-app-id,your-app-id- Accepts a comma-separated list of audience strings
- Azure AD v1 tokens use aud = api://{app-id}, v2 tokens often use aud = {app-id} (bare GUID), listing both covers both cases
- If a token's aud claim doesn't match any of the configured values, the request is rejected with 401
Option B: Shared secret (HMAC)
If your LLM app generates its own JWT tokens rather than using an OAuth provider, you can validate them with a shared secret.
-e AUTH_PROVIDER=jwt \
-e JWT_SECRET=your-secret-key-hereUse a long, randomly generated string. Tokens must use the HS256 algorithm and include exp and sub claims.
The shared secret must be kept confidential. Anyone who has it can generate valid tokens. Prefer JWKS validation when using an external identity provider.
OAuth 2.0 discovery (optional)
Some MCP clients (browser-based assistants, IDE integrations) don't let you paste a bearer token, they expect to run the OAuth flow themselves. For that to work, the server needs to tell them where to send the user for authentication. This is done via the RFC 9728 Protected Resource Metadata endpoint at /.well-known/oauth-protected-resource.
To enable discovery, set two additional environment variables:
-e MCP_PUBLIC_URL=https://mcp.your-domain.com \
-e OAUTH_AUTHORIZATION_SERVER=https://login.microsoftonline.com/{tenant-id}/v2.0- MCP_PUBLIC_URL the public URL clients reach the server on (behind your reverse proxy). When this is set, the server publishes metadata at /.well-known/oauth-protected-resource and sends a WWW-Authenticate: Bearer resource_metadata=... header on 401 responses. Leave unset to disable discovery (bearer-token-only mode).
- OAUTH_AUTHORIZATION_SERVER the authorization server's issuer URL. For Azure AD this is https://login.microsoftonline.com/{tenant-id}/v2.0. Without this, the metadata document is published but doesn't point clients anywhere.
If REQUIRED_SCOPES is set, those scopes are also advertised in the metadata as scopes_supported.
Dynamic Client Registration shim (for turnkey MCP clients)
Some MCP clients require RFC 7591 Dynamic Client Registration, they refuse to proceed unless they can POST to a registration_endpoint and receive a client ID. Most enterprise identity providers (Azure AD / Entra ID, Okta without admin consent, on-prem ADFS) don't offer DCR. The server can bridge this gap by accepting registration requests itself and handing out a pre-registered client ID you configure in your IdP.
The shim is provider-agnostic, it works with any OIDC provider. Activate it by setting OAUTH_REGISTERED_CLIENT_ID:
-e OAUTH_REGISTERED_CLIENT_ID=your-preconfigured-client-idWhen the shim is active the server also:
- Serves RFC 8414 AS metadata at /.well-known/oauth-authorization-server advertising itself as the authorization server
- Updates the Protected Resource Metadata so authorization_servers points at this server (clients discover our AS metadata rather than the upstream's)
- Serves /authorize (302 redirect to the upstream authorize endpoint, query params forwarded) and /token (reverse proxy to the upstream token endpoint)
How it finds upstream endpoints: on startup the server does OIDC discovery at {OAUTH_AUTHORIZATION_SERVER}/.well-known/openid-configuration to find authorization_endpoint and token_endpoint. If your provider doesn't support OIDC discovery, set both explicit overrides:
-e OAUTH_AUTHORIZE_URL=https://your-idp.example.com/oauth2/authorize \
-e OAUTH_TOKEN_URL=https://your-idp.example.com/oauth2/tokenNo tokens are issued or signed by this server. The shim only proxies endpoints and fakes registration. All JWTs continue to come from your upstream IdP and are validated against JWKS_URL as before.
What to do in your IdP: register one OAuth application manually with the client ID you'll pass as OAUTH_REGISTERED_CLIENT_ID. Register as many redirect URIs as you need (one per MCP client you plan to support). The shim hands out the same client ID to every MCP client that registers, each client uses its own redirect URI, which the IdP validates against the list you registered.
Testing authentication with MCP Inspector
Bearer token (simplest):
- Obtain a valid token from your identity provider
- Launch the Inspector: npx @modelcontextprotocol/inspector
- Select StreamableHttp, enter your URL, and expand Custom Headers
- Add header name Authorization with value Bearer your-token-here
- Click Connect
Full OAuth flow (requires discovery enabled):
- Launch the Inspector and select StreamableHttp
- Enter your server URL and open the Auth tab
- Inspector fetches metadata from /.well-known/oauth-protected-resource, redirects you to the authorization server (e.g. Microsoft login), and exchanges the resulting code for a token
- For this to work, register Inspector's redirect URI (shown in its UI) under Authentication in your Azure AD app registration
If you get HTTP 401 trying to load well-known OAuth metadata in Inspector, MCP_PUBLIC_URL isn't set. Verify the metadata endpoint is reachable without authentication:
curl https://mcp.your-domain.com/.well-known/oauth-protected-resourceConnecting your LLM application
Your LLM app connects to the MCP server using a Streamable HTTP MCP transport. The exact API depends on which MCP SDK your app uses, but the connection details are always:
- URL: https://mcp.your-domain.com (or http://localhost:8080 for local testing)
- Transport: Streamable HTTP (also called StreamableHTTP or HTTP+SSE depending on the SDK)
- Header: Authorization: Bearer <token> (when authentication is enabled)
The server exposes a single endpoint at / which handles both POST (for sending requests) and GET (for receiving server-sent events).
Configuration reference
Variable | Required | Default | Description |
|---|---|---|---|
FACTRY_HISTORIAN_API_URL | Yes | None | URL to your Factry Historian API |
FACTRY_HISTORIAN_API_TOKEN | Yes | None | Token for authenticating with Historian |
FACTRY_HISTORIAN_ORGANIZATION_UUID | Yes | None | UUID of the organization in Historian |
TRANSPORT_MODE | Yes | stdio | Set to http to enable HTTP mode |
HTTP_HOST | No | localhost | Interface to bind to. Default localhost binds to loopback only (127.0.0.1). Set to 0.0.0.0 to bind all interfaces |
HTTP_PORT | No | 8080 | Port to listen on |
AUTH_PROVIDER | No | none | Authentication method: none or jwt |
JWKS_URL | Conditional | None | JWKS endpoint for public key validation (RS256/ES256). Required when AUTH_PROVIDER=jwt and no JWT_SECRET is set |
JWT_SECRET | Conditional | None | Shared secret for HMAC validation (HS256). Required when AUTH_PROVIDER=jwt and no JWKS_URL is set |
REQUIRED_SCOPES | No | None | Comma-separated list of scopes that must be present in the token |
EXPECTED_AUDIENCE | No | None | Comma-separated list of accepted aud claim values. Strongly recommended to prevent cross-resource token reuse |
MCP_PUBLIC_URL | No | None | Public URL of this MCP server. When set, enables RFC 9728 OAuth discovery via /.well-known/oauth-protected-resource |
OAUTH_AUTHORIZATION_SERVER | No | None | OAuth authorization server issuer URL (e.g. https://login.microsoftonline.com/{tenant}/v2.0). Advertised in the discovery metadata and used for OIDC discovery when the DCR shim is enabled |
OAUTH_REGISTERED_CLIENT_ID | No | None | Pre-registered client ID to hand out on /register. Setting this activates the DCR shim (AS metadata, /register, /authorize, /token) |
OAUTH_AUTHORIZE_URL | No | None | Explicit upstream authorize endpoint. Overrides OIDC discovery if set. Useful when your IdP doesn't publish a .well-known/openid-configuration document |
OAUTH_TOKEN_URL | No | None | Explicit upstream token endpoint. Same semantics as OAUTH_AUTHORIZE_URL |
GRAFANA_URL | No | None | URL to your Grafana instance (enables Grafana tools) |
GRAFANA_API_KEY | No | None | Grafana service account token |
LOG_LEVEL | No | info | Log verbosity: debug, info, warn, error |
STATISTICS_DISABLED | No | false | Set to true to disable anonymous usage statistic |
Troubleshooting
The server is unreachable from my LLM app
- Check that the container is running: docker ps
- Verify the reverse proxy is running and listening on port 443
- Confirm DNS for your domain resolves to the correct server
- Test from the server itself: curl http://localhost:8080/health
Requests are rejected with 401 Unauthorized
- Confirm AUTH_PROVIDER=jwt is set and the token is included in the Authorization header
- Check the token has not expired
- Verify JWKS_URL is reachable from inside the container: docker exec historian-mcp wget -qO- $JWKS_URL
- Check the token contains the required scopes if REQUIRED_SCOPES is set
Server-Sent Events are not streaming (responses are delayed)
- Ensure proxy_buffering off is set in nginx, or that your reverse proxy does not buffer responses
- Some load balancers have their own buffering settings, consult your proxy's documentation
The container exits immediately
Run it without -d to see the error output:
docker run --rm \
-e FACTRY_HISTORIAN_API_URL=https://historian.example.com/api \
-e FACTRY_HISTORIAN_API_TOKEN=your-token \
-e FACTRY_HISTORIAN_ORGANIZATION_UUID=your-uuid \
-e TRANSPORT_MODE=http \
factry/mcp-server:latestCommon causes: missing or misspelled environment variables, invalid Historian API token, or an invalid AUTH_PROVIDER / TRANSPORT_MODE value.