import { type Express } from 'express'; import { McpServer as McpSdkServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { Server as HttpServer } from 'node:http'; import type { Server as HttpsServer } from 'node:https'; import { createInProcessMcp } from './inProcessClient'; export default class McpServer { private readonly adapter; /** The Express app we attach routes to. Undefined in embedded in-process mode (no HTTP). */ private readonly app?; /** Active sessions keyed by session id. */ private readonly sessions; /** Ref-count of adapter-level subscriptions across all sessions, keyed by ":
" or "log". */ private readonly subscriptionCounts; /** Recent log lines kept for `ioblog://` resource reads (filled while there are log subscribers). */ private readonly logBuffer; private config; private readonly extension; private readonly routerPrefix; /** The ioBroker user whose permissions every MCP request runs with. */ private readonly defaultUser; /** Default language used to localize device/room/function names. */ private readonly language; /** * Whether this instance has to authenticate incoming MCP requests itself. True only in standalone * mode with authentication enabled; as a web extension the host `web` adapter guards the routes. */ private readonly authRequired; /** * Whether *we* run the OAuth2 authorization server (login and consent pages, token issuance). * Standalone mode only — as a web extension the host `web` instance owns those endpoints. */ private readonly oauthProvider; /** * Whether the MCP endpoint is an OAuth-protected resource: we publish its metadata (RFC 9728), * point clients at the authorization server in the `401`, and reject tokens minted for something * else. True in standalone mode and as a web extension alike. */ private readonly oauthResource; /** OAuth2 login/token server, created only when {@link authRequired} is true. */ private oauth2?; constructor(server: HttpServer | HttpsServer | null, webSettings: { secure?: boolean; port?: number | string; defaultUser?: string; auth?: boolean; language?: ioBroker.Languages; /** Explicit override for the `set_state`/`set_states` permission (embedded mode). */ allowSetState?: boolean; /** Explicit override for the object/file-changing tool permission (embedded mode). */ allowObjectChange?: boolean; }, adapter: ioBroker.Adapter, instanceSettings: ioBroker.InstanceObject | null, app?: Express); /** * Build a fresh MCP SDK server with all ioBroker tools registered, ready to be connected to an * arbitrary transport. Used for in-process embedding (e.g. by ioBroker.admin) over an * InMemoryTransport, where there is no HTTP server/session layer. The caller owns the lifecycle * of the returned server (connect it to a transport, and `close()` it when done). */ createInProcessServer(): McpSdkServer; /** * Convenience factory for embedding the MCP server inside another adapter's process (no HTTP). * The returned instance is not wired to any Express app; obtain a tool server via * {@link createInProcessServer} and connect it to an in-memory transport. * * @param options embedding options (host adapter, default user, language, permission toggles) * @param options.adapter The host adapter into which the MCP server is embedded. Used for receiving state/object changes and logs, and for performing actions with the host adapter's permissions. * @param options.defaultUser Default user * @param options.language Language * @param options.allowSetState Is the state creation available * @param options.allowObjectChange If the object change available */ static createEmbedded(options: { adapter: ioBroker.Adapter; defaultUser?: `system.user.${string}`; language?: ioBroker.Languages; allowSetState?: boolean; allowObjectChange?: boolean; }): McpServer; private initRoutes; /** * Guard for the MCP endpoint when authentication is enabled (standalone mode). By the time this * runs, the global OAuth2 `authorize` middleware installed by {@link createOAuth2Server} has * already populated `req.user` from a Bearer token, an `access_token` cookie or HTTP Basic auth * — and answered with 401 itself if a *wrong* credential was supplied. A request with *no* * credential, however, falls through `authorize` with `req.user` still unset, so here we reject * anything that could not be tied to an authenticated ioBroker user. */ private authGuard; /** * The actual work behind {@link authGuard}: reject unauthenticated requests, and — when OAuth is * enabled — additionally reject tokens that were issued for a different resource. * * @param req The incoming request * @param res The response to write to * @param next Passed on when the request may proceed */ private checkAuthentication; /** * Read the stored record of an access token. * * Deliberately goes through the adapter's session storage rather than through an `OAuth2Model`: * as a web extension we have no model of our own, but `this.adapter` *is* the host `web` adapter * there, so this reads exactly the store its authorization server writes to. * * @param accessToken The Bearer token presented by the client */ private getTokenInfo; /** * Build the `WWW-Authenticate` challenge. With OAuth enabled it carries `resource_metadata` * (RFC 9728), which is how an MCP client discovers where to log in — without it, clients cannot * start the flow on their own. * * @param req The incoming request, used to derive the public URL * @param error Optional RFC 6750 error code * @param description Optional human-readable detail for the error */ private buildAuthenticateHeader; /** * Whether an access token's audience refers to this MCP endpoint. * * @param aud The `resource` the token was issued for * @param req The incoming request, used to derive the public URL */ private isOurAudience; /** * The externally reachable base URL, without a trailing slash. Behind a reverse proxy the * request-derived value is wrong, which is what the `publicUrl` setting is for. * * @param req The incoming request */ private getBaseUrl; /** * Called by the ioBroker web adapter to list this extension on its welcome/intro page. * Only relevant when running embedded. */ welcomePage(): { link: string; name: string; img: string; color: string; order: number; pro: boolean; }; /** Handle client -> server messages (initialize, tools/call, ...). */ private handleMcpPost; /** Handle server -> client SSE stream (GET) and session termination (DELETE). */ private handleMcpSessionRequest; /** * Forward an ioBroker state change to subscribed sessions. * * Called by our own adapter (standalone) or by the host web adapter (extension mode), which * invokes `stateChange` on every web extension that defines it. */ stateChange(id: string, _state: ioBroker.State | null | undefined): void; /** * Forward an ioBroker object change to subscribed sessions. Called by our own adapter * (standalone) or automatically by the host web adapter (extension mode). */ objectChange(id: string, _obj: ioBroker.Object | null | undefined): void; /** Push `resources/updated` to every session subscribed to the given state/object id. */ private notifySubscribers; /** Receive an ioBroker log line: buffer it and push `resources/updated` to ioblog subscribers. */ private onLog; /** Classify a resource URI into its subscription kind, address and ref-count key. */ private uriKind; /** Add an adapter-level subscription, subscribing on the adapter only on the first reference. */ private refSubscribe; /** Drop an adapter-level subscription, unsubscribing on the adapter when the last reference goes. */ private refUnsubscribe; /** Release all subscriptions held by a session (on close). */ private cleanupSession; /** * Create a new MCP SDK server with all ioBroker tools registered. * * @param subscriptions the per-session set of subscribed state ids (mutated by subscribe/unsubscribe) */ private createServer; /** Build the result entry for one state. */ private static stateEntry; private getStates; /** Write multiple states; failures of single states are reported per item and do not abort the rest. */ private setStates; /** * Parse the raw log-file lines returned by the host `getLogs` into structured entries. * * The host replies with an array of raw strings (the file size is appended as the last, numeric * element). Lines carry ANSI color codes and look like * `2026-06-12 11:46:39.802 - error: hm-rpc.0 (1234) Init not possible…`. This mirrors the admin * `LogsWorker` parsing: strip the color codes, then split timestamp / level / rest, and treat lines * without a leading timestamp as continuations (e.g. stack traces) of the previous entry. */ private parseLogLines; private getLogs; /** * Diagnose whether a network device/service is reachable: an ICMP ping to `host` and, if `port` * is given, a TCP connect to that port. Used to investigate adapter connection errors. */ private pingHost; /** ICMP ping via the OS `ping` command (no elevated privileges needed). */ private icmpPing; /** * Locate the `ping` executable. On Linux the adapter process sometimes runs with a minimal `PATH` * (e.g. only `/usr/bin`) while `ping` lives in `/bin` or `/sbin`, which makes a bare `ping` fail with * ENOENT. Probe the common absolute locations and fall back to the bare name otherwise. */ private resolvePingBinary; /** * Build a distro-appropriate command to install the `ping` tool, used in the recommendation when the * binary is missing. The distro is detected from the package manager / release files present on disk. */ private suggestPingInstall; /** * Reachability fallback when ICMP is unavailable/blocked: try a TCP connect to a handful of ports * that are commonly open on routers/devices. The host counts as reachable as soon as one port either * accepts the connection or actively refuses it (a RST also proves the host is up). */ private tcpReachable; /** TCP connect probe — tests whether a specific service port accepts connections. */ private tcpProbe; private getSystemInfo; private searchObjects; private listDevices; private historyQuery; private listInstances; /** List installed adapters (system.adapter. objects, no instances). */ private listAdapters; /** * Search the adapter repository (all installable adapters) by keyword. Reads the already-downloaded * repository object `system.repositories` — fast, no network — and matches against name, title, * description and keywords, with an optional category `type` filter and an `installed` flag. */ private searchAdapterRepository; private listHosts; /** * Read the rooms/functions enums with localized names and details about each member object * (ported from the ioBroker n8n node's `readIobEnums`). */ private readEnums; /** Write a state, coercing the value to the state's declared type (boolean/number/string). */ private setState; /** Coerce an arbitrary value to the given ioBroker state type. */ private static coerceValue; /** Read a file from an adapter file storage. */ private readFile; /** Write a file to an adapter file storage. */ private writeFile; /** List a directory in an adapter file storage. */ private listFiles; /** Rename/move a file within the same adapter file storage. */ private renameFile; /** Create or update an object, merging common/native into an existing object (n8n `setIobObject`). */ private setObject; /** Split a file path "//" into adapter name and file name. */ private static parseFilePath; /** Split a directory path "[/]" into adapter name and (possibly empty) directory. */ private static parseDirPath; /** Delete an object, optionally with all its children. */ private deleteObject; /** Create a new state object; refuses to overwrite an existing object. */ private createState; /** Create or update a scene object for the ioBroker "scenes" adapter. */ private createScene; /** Resolve the member ids of a room/function enum matched by id or (localized) name. */ private getEnumMembers; /** Normalize an ioBroker name (string or {en, de, ...}) to a plain string. */ private getName; /** Parse an interval like "15m", "1h", "30s" into milliseconds. */ private parseInterval; unload(): void; } export { McpServer, createInProcessMcp }; export type { McpConfig } from './types'; export type { InProcessMcp, InProcessMcpOptions, InProcessToolInfo, InProcessToolResult } from './inProcessClient';