Repository navigation
Architecture
┌──────────────── 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
@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.
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.
Per workspace the manager creates:
-
TxOperations: the Huly data client.createClient(endpoint, workspaceToken)from@hcengineering/server-clientopens the WebSocket and loads the workspace model.TxOperations(raw, socialId)writes transactions as the logged-in account. AllfindAll/findOne/createDoc/updateDoc/addCollection/removeDoc/searchFulltextcalls go through it. -
Workspace-level
AccountClient:getClient(HULY_ACCOUNTS_URL, workspaceToken). The workspace is implied by the token, which is required bygetWorkspaceMembers()(list_members).
- 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.
Each tool is a wrapToolHandler(async (args) => string):
- The input is validated by the MCP SDK against the zod schema. A missing required
workspaceis rejected here. - The handler resolves its workspace:
getConnection(args.workspace). - 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.
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
/filescontract was verified against ahuly-selfhostinstance:- 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.
-
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.
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.