Model Context Protocol (MCP)
Hub ships a generic Model Context Protocol server shared by every Hub-based application. An MCP client (for example Claude Code) connects to a single stateless POST /mcp JSON-RPC endpoint and calls tools. The set of advertised tools is not hard-coded — it is collected at request time from every connector method decorated with @MCP, so an end application exposes its own tools the same way it exposes REST with @Route: by decorating a method, with no MCP server code of its own.
The whole feature lives in CORE:
| Concern | Type | Location |
|---|---|---|
| Tool decorator + registry | @MCP, McpToolRegistry | io-oidis-hub · Io/Oidis/Hub/Utils/McpTool.ts |
| Transport endpoint | McpConnector (POST /mcp) | io-oidis-hub · Io/Oidis/Hub/Connectors/McpConnector.ts |
| Token issuance | AuthManager.IssueMcpToken | io-oidis-hub · Io/Oidis/Hub/AuthProcessor/AuthTokensManager.ts |
mcp: token gate | IsAuthorizedFor | io-oidis-hub · Io/Oidis/Hub/AuthProcessor/BasicAuthenticationAPI.ts |
| Enablement flags | IProject.mcp, AppSettings.McpEnabled | io-oidis-hub · Interfaces/IProject.ts, DAO/Models/AppSettings.ts |
| Registration window | McpRegisterPage + controller | io-oidis-hub-gui · Pages/McpRegisterPage.ts, Controllers/Pages/McpRegisterPageController.ts |
The endpoint uses @modelcontextprotocol/sdk, declared in io-oidis-hub devDependencies and lazy require()d inside McpConnector so its types stay out of the TypeScript path map. Downstream apps inherit it transitively through extendsConfig — no per-project install.
Exposing a tool — the @MCP decorator
@MCP marks a connector method as an MCP tool. The method keeps its existing @Extern / @Route behavior for direct REST access; the decorator additionally registers it in the global tool registry that McpConnector reads. Stack it together with @Extern:
import { MCP } from "@io-oidis-hub/Io/Oidis/Hub/Utils/McpTool.js";
@MCP({
description: "Returns the worklog-vs-bank margin reconciliation for one release.",
inputSchema: {
type : "object",
properties: {
release: {type: "string", description: "Release identifier, e.g. 2026.1"}
},
required : ["release"]
}
})
@Extern()
public async VerifyReleaseMargin($release : string) : Promise<IMarginReport> {
// ...regular connector method, also reachable over REST...
}
IMcpToolOptions:
| Field | Required | Meaning |
|---|---|---|
description | yes | Human / LLM readable text shown by MCP clients in tools/list. |
inputSchema | yes | JSON Schema (draft-07) of the arguments. Also derives the positional argument order. |
name | no | Tool name advertised over MCP. Defaults to the decorated method name. |
args | no | Ordered inputSchema.properties keys to spread as positional method arguments. Defaults to Object.keys(inputSchema.properties) in declaration order. |
passObject | no | When true, the whole arguments object is passed as a single argument instead of being spread. Use for methods that take one structured options object. |
Argument mapping at tools/call time:
passObject: true→method(argsObject).- otherwise →
method(...args.map((key) => argsObject[key])), whereargsdefaults to the schema property keys. So in the example above the MCP argumentreleaseis passed as the first positional parameter$release.
A connector class is only registered if it is loaded. Add an import "./Path/To/YourConnector.js"; to your project's ForceCompile.ts (the same place @Route connectors are listed) or the tool will not appear in tools/list.
The registry is exposed read-only via McpToolRegistry.getDescriptors(), McpToolRegistry.find(name) and McpToolRegistry.getMethodNames().
The endpoint — POST /mcp
McpConnector.McpHandle is a single @Route("POST /mcp", {responseType: ExternResponseType.FULL_CONTROL}) handler. Each request:
- Validates the JSON-RPC 2.0 envelope and that MCP is enabled (see below).
- After authenticating the caller, acknowledges notifications (
notifications/*or any message without anid) with HTTP 202 Accepted and no body, as required by the Streamable HTTP transport. A JSON{}response is not a valid JSON-RPC message and causes strict clients such as Codex to reject the initialization handshake. - Builds a fresh, stateless SDK
Server— no state is shared across requests (apart from the list-change bookkeeping below). - Registers
tools/listandtools/callhandlers (the SDK natively servesinitializeandping). - Pipes the message through an
OidisMcpTransportadapter that bridges the SDKTransportinterface to OidisRESTResponse.
The advertised server identity (name / version) comes from mcp.serverName, falling back to <target.name>-mcp, then oidis-hub-mcp; version is IProject.version.
New tools after a deploy — tools.listChanged
The server declares capabilities.tools.listChanged: true. Without a held stream it cannot push notifications, so
the session itself records the tool set:
initializeanswers with anMcp-Session-Idof the form<fingerprint>-<uuid>, where the fingerprint is a hash of the tools the caller may see (McpConnector.ToolsFingerprint).- A later request of that session (not
tools/listitself) whose fingerprint no longer matches — typically after a deploy added, removed or redescribed a tool — is answered astext/event-stream(only when the client'sAcceptallows it):notifications/tools/list_changedfirst, then the response. Each session is told once per change (McpConnector.ShouldNotify, kept in memory).
Clients that honour the notification refetch tools/list on their own. The Claude desktop app (Code tab) does not
yet refresh claude.ai connectors in an existing session (anthropics/claude-code#100115);
new tools appear there only in a new top-level session or after an app restart, and switching the connector off and
on does not help.
owner.Send must be lowercaseThe Localhost HttpServer merges response headers with its defaults by exact key, and the default is
"content-type": "text/html". A "Content-Type" key therefore produces two Content-Type headers. McpConnector
sends content-type, mcp-session-id and www-authenticate in lowercase for this reason.
Authorization — unchanged AuthManager model
MCP introduces no new permission system. Access is governed by the existing AuthManager, user-managed tokens, and GUI-driven registration.
tools/listis filtered per tool: a tool is advertised only whenAuthManager.IsAuthorizedFor(descriptor.methodName(), callerToken)is true. Each caller sees only the tools they may run.tools/callre-checksIsAuthorizedForbefore dispatching, then resolves the owning connector through the connectors register and invokes the method. The caller token is wired onto the entity sogetCurrentToken()works inside the tool.
mcp:-prefixed tokens
AuthManager.IssueMcpToken($clientName, $ttlDays = 365) issues a token for the calling user (anonymous issuance throws). The token's name is prefixed mcp:<clientName> (client name sanitized to [a-zA-Z0-9_-], max 64 chars). $ttlDays <= 0 means no expiration.
The prefix is enforced in BasicAuthenticationAPI.IsAuthorizedFor: a token whose name starts with mcp: is authorized only on the MCP transport — the McpHandle route and the per-tool dispatch that re-enters IsAuthorizedFor for registered tools. Every other API path is denied, so a leaked MCP bearer cannot be used as a generic admin session. Session cookies are not AuthToken records, so they bypass this gate unchanged.
Because an mcp: bearer is a random hash (not a JWT), IsAuthorizedFor resolves it to its issuing owner via AuthManager.getUserByAuthToken and evaluates per-tool access against that user's groups. An MCP token therefore runs exactly the tools its issuer is authorized for. Grant the @MCP methods to the issuer's group (for example Admin) the same way you would any other @Extern method — otherwise tools/list comes back empty and tools/call returns "Not authorized".
Inside a tool, getCurrentToken() returns that same mcp: bearer, and AuthManager.getUser(token) / getGroups(token)
do not resolve it — code that identifies the caller that way sees an anonymous user and silently returns empty
results. Resolve the caller as getUser(token), falling back to getUserByAuthToken(token), and read groups by the
resolved user id (getGroups(user.Id)). Any access helper shared by the GUI RPC and the tools should do this once, so
both paths apply the same visibility rules.
What an mcp: token can do
The gate narrows the transport, not the tool set. An mcp: token is not scoped to a selection of tools: it runs
every @MCP method of every enabled application that its issuer is granted, including tools that write or delete
data. For an administrator of an application with many tools that is a broad surface, so a leaked admin MCP token is
close to a leaked admin session for everything exposed as a tool. It is also a long-lived secret sent with every
request.
Choose the credential by the caller:
| Caller | Credential | Why |
|---|---|---|
| An AI client (Claude Desktop/Code connector) | OAuth (mcp:oauth-…) or a static mcp: token | The client speaks MCP and holds the token itself |
| A local tool or script acting as the user (CLI, desktop shell, hook) | A user AuthToken exchanged at POST /api/login ({user, pass: <token>}) for a JWT session, then the JWT as the Bearer of the @Route calls | The long-lived token crosses the wire once per exchange and only an expiring JWT travels with the calls; the server sees who signs in |
Notes on the exchange: login by token updates the user's sign-in audit, forced 2FA applies as for a password, and the
JWT shares the user's session payload, so the tool must not call LogOut (it clears that payload and would end
the user's other sessions); the JWT simply expires.
Enabling MCP
Enablement follows the standard Application Settings pattern: config.mcp.enabled is only the default — the live state lives in the DB flag AppSettings.McpEnabled, toggled at runtime from the admin App settings without a redeploy (same model as the mail engine, auto-restart, etc.). McpConnector.isEnabled() reads only the DB flag:
private static async isEnabled() : Promise<boolean> {
const settings : AppSettings = await Loader.getInstance().getEnvironmentHelper().getAppSettings();
return !ObjectValidator.IsEmptyOrNull(settings) && settings.McpEnabled === true;
}
Off by default. commons.jsonp ships mcp.enabled: false; AppSettings.SyncFrom seeds McpEnabled from it (this.McpEnabled = $config.mcp.enabled === true), just like Sendmail / AutoActivateRegistered. To ship MCP on by default for a deployment, set it in the project config:
mcp: {
enabled : true, // seeds the AppSettings.McpEnabled default
serverName: "my-app-mcp" // optional; empty falls back to "<target.name>-mcp"
}
Typed as IProject.mcp : IMcpConfiguration — enabled + serverName cover the transport; the OAuth + consent-branding fields are added in OAuth 2.1 authorization below.
Runtime toggle. Admins flip MCP on/off in App settings ("MCP (Model Context Protocol)"); the change persists to AppSettings.McpEnabled and applies immediately. Whether a later config redeploy re-seeds that value is governed by preferDBSettings (false → JSONP re-seeds on version change; true → DB wins).
When disabled, POST /mcp answers JSON-RPC error -32601 "MCP endpoint is disabled".
Registration window — /mcp/register
hub-gui serves an MCP registration / token-issuance window at /mcp/register (@Resolver("/mcp/register"), McpRegisterPageController → McpRegisterPage). It tells the operator which application and which instance an MCP client is connecting to, lets them name the client and set validity, and issues a one-time-display token plus a ready-to-paste Authorization: Bearer … snippet. The instance line defaults to the served host (getHttpManager().getRequest().getHostUrl()) plus the build version.
Overriding the window in an end app
The window is overridable two ways, mirroring how /login is customized:
Light — subclass and override hooks. McpRegisterPage exposes getLocalization() (returns IMcpRegisterPageLocalization, every visible string, English by default) plus the getAppTitle() / getInstanceLabel() identity hooks. Override getLocalization() to translate the whole window, then subclass the controller and override its getPageInstance() factory to return your page:
export class MyMcpRegisterPage extends McpRegisterPage {
public getLocalization() : IMcpRegisterPageLocalization {
return {
...super.getLocalization(),
heading : "Connect your assistant",
generateButton: "Generate token"
};
}
}
@Resolver("/mcp/register")
export class MyMcpRegisterPageController extends McpRegisterPageController {
protected getPageInstance() : McpRegisterPage {
return new MyMcpRegisterPage();
}
}
Full — re-register the route. Declare your own controller + page with @Resolver("/mcp/register"); the app-level resolver wins over the CORE one (the same route-precedence mechanism that lets an app replace /login, / or /dashboard).
OAuth 2.1 authorization (browser sign-in)
Beside the hand-pasted static mcp: token, Hub can run a full OAuth 2.1 authorization server so an MCP client (Claude Desktop/Code) connects with Add connector → Authorize — a browser sign-in + consent — and never handles a raw token. It implements the subset the MCP Authorization spec expects: RFC 9728 protected-resource metadata, RFC 8414 authorization-server metadata, RFC 7591 dynamic client registration, the authorization-code grant with mandatory PKCE (S256), and RFC 8707 resource indicators. The static token stays as a fallback and the POST /mcp transport is unchanged.
| Concern | Type | Location |
|---|---|---|
| OAuth endpoints (discovery, DCR, authorize, token) | McpOAuthConnector | io-oidis-hub · Io/Oidis/Hub/Connectors/McpOAuthConnector.ts |
| Back-channel token issuance | AuthManager.IssueMcpOAuthToken | io-oidis-hub · AuthProcessor/AuthTokensManager.ts |
| Auth-code + client models | OAuthAuthCode, OAuthClient | io-oidis-hub · DAO/Models/ |
| Bearer-token resolver | McpRestResolver | io-oidis-hub · HttpProcessor/Resolvers/McpRestResolver.ts |
Enabling it
OAuth needs no separate flag — it is served whenever MCP is enabled (AppSettings.McpEnabled). By default the issuer (the public origin every discovery document, the WWW-Authenticate challenge and the PKCE audience are anchored to) is derived from the incoming request, honouring X-Forwarded-Proto / X-Forwarded-Host. A plain deployment behind a proxy or tunnel therefore needs no OAuth config at all:
mcp: {
enabled : true,
issuerUrl: "https://labs.example.com" // optional override; only when auto-derivation is wrong
}
issuerUrl is an optional override — set it only when the request-derived origin is not the exact public origin the client reaches. When present it wins over derivation.
The flow
- Client
POST /mcpwithout a token →McpConnectoranswers401+WWW-Authenticate: Bearer resource_metadata="<issuer>/.well-known/oauth-protected-resource". This challenge is what tells the client to run OAuth and attach a Bearer afterwards; without it the client reads the plain200JSON-RPC reply as "connected, no auth needed" and never sends a token. The challenge fires whenever the caller sent no token and an issuer can be resolved (derived from the request, or theissuerUrloverride) — so the static-token path, which always carries a Bearer, is unaffected. - Client fetches
/.well-known/oauth-protected-resource(RFC 9728), then/.well-known/oauth-authorization-server(RFC 8414) → the authorize/token/registration endpoints,code+ PKCES256, public client (token_endpoint_auth_method: "none"). POST /mcp/register(RFC 7591 DCR) → the client self-registers its redirect URIs and receives aclient_id(no secret). Re-registering the same redirect URIs returns the existingclient_id, so a connector remove + re-add keeps a stable client identity.GET /mcp/authorize?...→ the server validates the client and the exact redirect URI, then renders the consent page (an SPA window). The consent page never handles credentials: when the browser carries no app session it hands off to the standard/login— password + 2FA + SSO, exactly like any other authenticated-only page — and returns to the consent page once signed in. The signed-in account is shown on the page; approving binds the grant to that fully-authenticated session and issues a single-use authorization code.POST /mcp/token(grantauthorization_code+ PKCE verifier) → atomically claims the code and returns{access_token, token_type: "Bearer", expires_in, scope}. The access token is anmcp:oauth-<clientId>AuthTokenbound to the resource owner — the samemcp:family the static flow issues, so the per-tool gate inIsAuthorizedForapplies unchanged.- The client retries
POST /mcpwith the Bearer → the resolver preserves it asgetCurrentToken(),IsAuthorizedForresolves the owner, andtools/listreturns exactly the owner's tools.
The Bearer resolver (built in)
The default REST validator only understands JWT session tokens; for a non-JWT mcp: bearer it would fall back to a service account and drop the token before McpHandle sees it. CORE therefore ships McpRestResolver, which claims the /mcp, /mcp/* and /.well-known/* paths, preserves the raw bearer as getCurrentToken(), and leaves each handler to gate itself per tool (discovery + OAuth endpoints stay public; the transport self-gates). It is registered in the Hub's own ForceCompile.ts, so a consuming app needs no wiring for it — enabling MCP is enough.
Branding the consent page
The consent page reads all branding from config (never hardcoded), with CORE defaults. Set any of these under mcp:
| Field | Default | Purpose |
|---|---|---|
displayName | twoFactorAuthentication.identityName → name | Brand in the heading + <title> |
logoUri | /resource/graphics/Io/Oidis/Hub/Gui/LogoSymbol.png | Header logo (same-origin path) |
consentTitle | "Authorize access" | <h1> heading |
consentSubtitle | (empty) | Optional line under the heading |
headerColor | (empty → transparent) | Optional colour band behind the logo (e.g. for a light-on-dark wordmark) |
Full IMcpConfiguration:
interface IMcpConfiguration {
enabled : boolean;
serverName? : string;
issuerUrl? : string;
displayName? : string;
logoUri? : string;
consentTitle? : string;
consentSubtitle? : string;
headerColor? : string;
}
Fully overriding the consent page
When config branding is not enough (custom layout, own validation), a solution subclasses McpOAuthConnector, overrides the protected render hooks (consentPage, brandName, logoUrl, pageTitle, pageSubtitle, headerColor, escapeHtml) and registers the subclass so the framework instantiates it in place of CORE — the same symbol-mapping mechanism used for AuthManager / PrintManager:
// solution Loader.onEnvironmentLoad()
this.getConnectorsRegister().setSymbolMapping({
symbol: "Io.Oidis.Hub.Connectors.McpOAuthConnector",
value : LabsMcpOAuthConnector.ClassName()
});
The subclass declares no @Route of its own (the base routes stay registered); the symbol mapping only redirects instantiation, so normal JS prototype dispatch runs the overridden methods. Import the subclass in ForceCompile.ts and keep the base import too (its @Route decorators must still run).
Claude Desktop caches the DCR registration + issued token keyed by the connector URL. After changing server config, force a fresh OAuth by removing + re-adding the connector, or point it at a fresh URL (a new tunnel URL is the simplest cache-miss). The 401 challenge also self-heals a stale/expired cached token — the client re-runs OAuth instead of failing silently.
Built-in diagnostic tools
Io.Oidis.Hub.Connectors.SystemDiagnostics ships five generic tools with the hub itself — every
hub-based application exposes them with zero per-app wiring (the connector is imported by the hub's
own ForceCompile.ts):
| Tool | What it returns | Arguments |
|---|---|---|
system_health | Version, build time, uptime, platform, node version, cores, process memory (rss/heap/external), machine memory, load average | — |
system_stats | HTTP traffic statistics from the built-in metrics collector | — |
log_tail | Last lines of the daily log file (<cwd>/log/YYYY/MM/DD_MM_YYYY.txt), at most the trailing 512 KB | date (YYYY-MM-DD, empty = today UTC), lines (default 200, max 1000), filter (case-insensitive substring) |
log_search | Matching log lines, newest first, across up to 7 days | query (required), days (default 1, max 7), limit (default 100, max 500) |
users_dormant | Active accounts that never signed in or whose last sign-in is older than the threshold, longest inactive first (AuthManager.FindDormantUsers, see Dormant accounts) | inactiveDays (default 180), neverSignedInOnly (true keeps only never signed-in accounts) |
No AuthManager grant is registered for them on purpose: only roles holding the "*" permission may
call them (log content and the user list are as sensitive as the application itself). tools/list hides them from every
other token, so a non-admin MCP client simply never sees them.
End-to-end checklist
- Decorate one or more connector methods with
@MCPand add the connector toForceCompile.ts. - Enable MCP: set
mcp.enabled: truein the project config (the default), or toggle it on at runtime in admin App settings. - Log in, open
/mcp/register, name the client, generate a token (shown once). - Configure the MCP client with
POST <hub-url>/mcpandAuthorization: Bearer <token>. - The client's
tools/listshows exactly the tools the token's user is authorized for;tools/callruns them through the normalAuthManagerchecks.
Or, for OAuth browser sign-in instead of a pasted token: keep mcp.enabled: true (OAuth is served automatically, the issuer derived from the request), point the client at <hub-url>/mcp, and Authorize — the browser bounces through the standard login (password + 2FA) and back to consent. See OAuth 2.1 authorization.
See also: Application settings | Routing