Skip to content

Architecture

JoKeks2023 edited this page Sep 28, 2026 · 1 revision

Architecture

Components

┌──────────────── MCP client (Claude, Cursor, …) ────────────────┐
│  stdio  (or HTTP via mcp-proxy in Docker)                      │
└───────────────────────────────┬────────────────────────────────┘
                                ▼
 src/index.ts      McpServer + StdioServerTransport, SIGINT/SIGTERM → closeConnection()
 src/server.ts     registers 48 tools + server instructions
 src/schemas.ts    zod input schemas (workspace: required for writes, optional for reads)
 src/tools/*.ts    tool handlers  →  getConnection(args.workspace) / getWorkspaceInfo(args.workspace)
                                ▼
 src/connection.ts WorkspaceConnectionManager
   ├─ account session ── account-level AccountClient (account token) ── HULY_ACCOUNTS_URL
   │                        login / getLoginInfoByToken / getUserWorkspaces / selectWorkspace
   ├─ workspace list (discovered, cached)
   └─ per workspace UUID (cached):
        ├─ TxOperations over WebSocket ── createClient(endpoint, workspace token) ── transactor
        ├─ workspace-level AccountClient (workspace token) ── getWorkspaceMembers
        └─ workspace token + UUID ── src/utils/storage.ts / content fetches ── HULY_FRONT_URL /files

Account client (account level)

@hcengineering/account-client → getClient(HULY_ACCOUNTS_URL, accountToken).

Call Purpose
login(email, password) Email/password login → account token + socialId (from an unauthenticated client)
getLoginInfoByToken() Validates HULY_TOKEN; tells account token and workspace token apart
getUserWorkspaces() Workspace discovery
selectWorkspace(slug, 'external') Per workspace: WebSocket endpoint + workspace token + UUID

There is exactly one account-level client per process (per login). It never holds a workspace token.

Workspace Connection Manager

WorkspaceConnectionManager (in src/connection.ts) owns all Huly state of the process:

State Cached as Invalidated
Account session one promise on login failure; on closeAll()
Workspace list one promise on discovery failure; refreshed once on unknown workspace; on closeAll()
Connections Map<workspaceUuid, promise> on connect failure (that entry only); on closeAll()

Public methods: listWorkspaces(), resolveWorkspace(input?), getWorkspace(input?), closeAll().

Module-level helpers used by the tools and scripts (unchanged names from the single-workspace version, now with a workspace parameter):

Function Returns
getConnection(workspace?) TxOperations of that workspace
getWorkspaceInfo(workspace?) { wsToken, workspaceUuid } of that workspace
getAccountClient(workspace?) workspace-level AccountClient of that workspace
getWorkspace(workspace?) all of the above plus the resolved workspace
listWorkspaces() discovered workspaces
closeConnection() closes everything

The process-wide manager is created lazily from the environment (getConnectionManager()). Tests swap it with setConnectionManager(). It also accepts injected createAccountClient / openConnection factories, which is how the tests run without a Huly server.

Resolution rules (workspace matching, reads vs. writes, legacy filter) are described in Multi-Workspace.

Workspace client (workspace level)

Per workspace the manager creates:

  • TxOperations: the Huly data client. createClient(endpoint, workspaceToken) from @hcengineering/server-client opens the WebSocket and loads the workspace model. TxOperations(raw, socialId) writes transactions as the logged-in account. All findAll/findOne/createDoc/updateDoc/addCollection/removeDoc/searchFulltext calls go through it.
  • Workspace-level AccountClient: getClient(HULY_ACCOUNTS_URL, workspaceToken). The workspace is implied by the token, which is required by getWorkspaceMembers() (list_members).

Huly WebSocket

  • The endpoint comes from selectWorkspace(…, 'external'). For self-hosted Huly it must be reachable from where the MCP server runs (also from inside Docker).
  • Opening a connection loads the full workspace model (1–3 s or more). The Huly SDK may print messages like "no document found, failed to apply model transaction, skipping …" to stderr at this point. That is SDK-internal and harmless.
  • Connections stay open until shutdown.

MCP tools

Each tool is a wrapToolHandler(async (args) => string):

  1. The input is validated by the MCP SDK against the zod schema. A missing required workspace is rejected here.
  2. The handler resolves its workspace: getConnection(args.workspace).
  3. It returns Markdown text. Errors are rethrown as Huly MCP: <message> and reach the client as tool errors.

Server instructions (sent to the client at initialize) tell the agent to call list_workspaces first, always pass workspace for changes, never guess it, and keep IDs with their workspace.

Storage / /files

Huly stores rich text (document bodies, issue/organization descriptions) as markup blobs referenced by ID, and attachments as blobs. Code: src/utils/storage.ts, plus the fetches in get_document, get_issue and get_organization.

Operation HULY_FRONT_URL set (self-hosted contract) HULY_FRONT_URL unset (Huly Cloud)
Upload POST {front}/files (multipart field file) POST https://dl-eu.huly.app/upload/form-data/{workspaceUuid}
Delete DELETE {front}/files?file={blobId} DELETE https://dl-eu.huly.app/blob/{workspaceUuid}/{blobId}
Read content GET {front}/files?file={blobId}&workspace={workspaceUuid} not fetched; blob ref shown with a hint
Auth Authorization: Bearer <workspace token> Authorization: Bearer <workspace token>
  • Every request uses the token and UUID of the workspace named in the tool call.
  • No request puts a token into the URL, and no tool result contains a token (see Security).
  • The self-hosted /files contract was verified against a huly-selfhost instance:
    • valid Bearer header → authorized
    • no or invalid token → 401
    • Bearer behaves exactly like the previously used token= query parameter.

huly-selfhost (0.7.x) doesn't run Cloud's datalake service, which is why this fork routes blob traffic through front's /files when HULY_FRONT_URL is set.

Transports

  • stdio: src/index.ts, the standard MCP transport for local clients.
  • HTTP: not built into the server. The Docker image wraps the stdio server with mcp-proxy. See Docker Deployment.

Dependencies

Runtime: @hcengineering/* 0.7.x SDK packages, @modelcontextprotocol/sdk, ws, zod. No database, cache or extra service. All state lives in process memory and is rebuilt on restart.

Clone this wiki locally