Skip to main content

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:

ConcernTypeLocation
Tool decorator + registry@MCP, McpToolRegistryio-oidis-hub · Io/Oidis/Hub/Utils/McpTool.ts
Transport endpointMcpConnector (POST /mcp)io-oidis-hub · Io/Oidis/Hub/Connectors/McpConnector.ts
Token issuanceAuthManager.IssueMcpTokenio-oidis-hub · Io/Oidis/Hub/AuthProcessor/AuthTokensManager.ts
mcp: token gateIsAuthorizedForio-oidis-hub · Io/Oidis/Hub/AuthProcessor/BasicAuthenticationAPI.ts
Enablement flagsIProject.mcp, AppSettings.McpEnabledio-oidis-hub · Interfaces/IProject.ts, DAO/Models/AppSettings.ts
Registration windowMcpRegisterPage + controllerio-oidis-hub-gui · Pages/McpRegisterPage.ts, Controllers/Pages/McpRegisterPageController.ts
SDK dependency

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:

FieldRequiredMeaning
descriptionyesHuman / LLM readable text shown by MCP clients in tools/list.
inputSchemayesJSON Schema (draft-07) of the arguments. Also derives the positional argument order.
namenoTool name advertised over MCP. Defaults to the decorated method name.
argsnoOrdered inputSchema.properties keys to spread as positional method arguments. Defaults to Object.keys(inputSchema.properties) in declaration order.
passObjectnoWhen 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])), where args defaults to the schema property keys. So in the example above the MCP argument release is passed as the first positional parameter $release.
Bundle the connector

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:

  1. Validates the JSON-RPC 2.0 envelope and that MCP is enabled (see below).
  2. After authenticating the caller, acknowledges notifications (notifications/* or any message without an id) 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.
  3. Builds a fresh, stateless SDK Server — no state is shared across requests (apart from the list-change bookkeeping below).
  4. Registers tools/list and tools/call handlers (the SDK natively serves initialize and ping).
  5. Pipes the message through an OidisMcpTransport adapter that bridges the SDK Transport interface to Oidis RESTResponse.

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:

  • initialize answers with an Mcp-Session-Id of 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/list itself) whose fingerprint no longer matches — typically after a deploy added, removed or redescribed a tool — is answered as text/event-stream (only when the client's Accept allows it): notifications/tools/list_changed first, 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.

Header keys passed to owner.Send must be lowercase

The 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/list is filtered per tool: a tool is advertised only when AuthManager.IsAuthorizedFor(descriptor.methodName(), callerToken) is true. Each caller sees only the tools they may run.
  • tools/call re-checks IsAuthorizedFor before dispatching, then resolves the owning connector through the connectors register and invokes the method. The caller token is wired onto the entity so getCurrentToken() 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:

CallerCredentialWhy
An AI client (Claude Desktop/Code connector)OAuth (mcp:oauth-…) or a static mcp: tokenThe 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 callsThe 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.

ConcernTypeLocation
OAuth endpoints (discovery, DCR, authorize, token)McpOAuthConnectorio-oidis-hub · Io/Oidis/Hub/Connectors/McpOAuthConnector.ts
Back-channel token issuanceAuthManager.IssueMcpOAuthTokenio-oidis-hub · AuthProcessor/AuthTokensManager.ts
Auth-code + client modelsOAuthAuthCode, OAuthClientio-oidis-hub · DAO/Models/
Bearer-token resolverMcpRestResolverio-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​

  1. Client POST /mcp without a token → McpConnector answers 401 + 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 plain 200 JSON-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 the issuerUrl override) — so the static-token path, which always carries a Bearer, is unaffected.
  2. Client fetches /.well-known/oauth-protected-resource (RFC 9728), then /.well-known/oauth-authorization-server (RFC 8414) → the authorize/token/registration endpoints, code + PKCE S256, public client (token_endpoint_auth_method: "none").
  3. POST /mcp/register (RFC 7591 DCR) → the client self-registers its redirect URIs and receives a client_id (no secret). Re-registering the same redirect URIs returns the existing client_id, so a connector remove + re-add keeps a stable client identity.
  4. 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.
  5. POST /mcp/token (grant authorization_code + PKCE verifier) → atomically claims the code and returns {access_token, token_type: "Bearer", expires_in, scope}. The access token is an mcp:oauth-<clientId> AuthToken bound to the resource owner — the same mcp: family the static flow issues, so the per-tool gate in IsAuthorizedFor applies unchanged.
  6. The client retries POST /mcp with the Bearer → the resolver preserves it as getCurrentToken(), IsAuthorizedFor resolves the owner, and tools/list returns 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.

The consent page reads all branding from config (never hardcoded), with CORE defaults. Set any of these under mcp:

FieldDefaultPurpose
displayNametwoFactorAuthentication.identityName → nameBrand in the heading + <title>
logoUri/resource/graphics/Io/Oidis/Hub/Gui/LogoSymbol.pngHeader 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;
}

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).

Client-side cache

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):

ToolWhat it returnsArguments
system_healthVersion, build time, uptime, platform, node version, cores, process memory (rss/heap/external), machine memory, load average—
system_statsHTTP traffic statistics from the built-in metrics collector—
log_tailLast lines of the daily log file (<cwd>/log/YYYY/MM/DD_MM_YYYY.txt), at most the trailing 512 KBdate (YYYY-MM-DD, empty = today UTC), lines (default 200, max 1000), filter (case-insensitive substring)
log_searchMatching log lines, newest first, across up to 7 daysquery (required), days (default 1, max 7), limit (default 100, max 500)
users_dormantActive 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​

  1. Decorate one or more connector methods with @MCP and add the connector to ForceCompile.ts.
  2. Enable MCP: set mcp.enabled: true in the project config (the default), or toggle it on at runtime in admin App settings.
  3. Log in, open /mcp/register, name the client, generate a token (shown once).
  4. Configure the MCP client with POST <hub-url>/mcp and Authorization: Bearer <token>.
  5. The client's tools/list shows exactly the tools the token's user is authorized for; tools/call runs them through the normal AuthManager checks.

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