Repository navigation
Multi Workspace
One Huly account can belong to several workspaces, e.g. a personal workspace and a company workspace. huly-mcp-selfhost serves all of them from one server process with one login, and you don't configure a workspace. This page describes exactly how that works and which rules protect you from acting in the wrong workspace.
Implementation: WorkspaceConnectionManager in src/connection.ts.
HULY_EMAIL + HULY_PASSWORD (or HULY_TOKEN)
│
▼
account login ─────────────── once per process, cached
│ account token + socialId
▼
getUserWorkspaces() ───────── discovery, cached
│ [{ uuid, url (slug), name, mode, isDisabled }, …]
▼
WorkspaceConnectionManager
├── "personal" → selectWorkspace("personal") → endpoint + workspace token → WebSocket (cached)
├── "acmeteam" → selectWorkspace("acmeteam") → endpoint + workspace token → WebSocket (cached)
└── …only opened when a tool first needs it
│
▼
MCP tools — every call names its workspace
The first time a tool runs (or list_workspaces), the manager logs in once at account level (see Authentication). The result is an account session:
- an account-level client, i.e.
@hcengineering/account-clientcreated with the account token - the account's socialId, the identity under which all transactions are written in every workspace
The session is shared by all workspaces. A failed login is not cached, so the next call retries.
The account-level client calls getUserWorkspaces(). That is the real API of @hcengineering/account-client (≥ 0.7), and it returns WorkspaceInfoWithStatus[]. The manager keeps these fields per workspace:
| Field | Meaning | Shown by list_workspaces
|
|---|---|---|
uuid |
Workspace ID, globally unique | as id
|
url |
Slug, as in huly.app/workbench/<slug>
|
as slug
|
name |
Display name | yes |
mode |
e.g. active, upgrading, archived
|
only if not active
|
isDisabled |
Workspace disabled | as disabled
|
The list is cached. It is refreshed once if a tool asks for a workspace that isn't in the list, e.g. because the account joined a workspace after the server started. Disabled or non-active workspaces are listed; connecting to them fails with Huly's own error.
Every tool except list_workspaces has a workspace argument. It accepts, case-insensitively:
- the ID (
uuid), - the slug (
url), - the display name, but only if exactly one workspace has that name.
Matching order: ID or slug first, then name. So workspace="acme" picks the workspace whose slug is acme even if another workspace is named "Acme". There is no prefix or fuzzy matching, deliberately: "acme" does not match a workspace with slug acmecodingworkspace and name "Acme Coding Workspace". Use list_workspaces to get the exact slug.
| Input | Result |
|---|---|
| Exact ID, slug or unique name | That workspace |
| Name shared by two workspaces | Error: "Workspace name '…' is ambiguous — use the slug or ID instead: …" |
| Unknown value | List refreshed once, then error: "Unknown workspace '…'. Available: …" |
"" or only spaces |
Error: "Workspace must not be empty (see list_workspaces)." |
| Omitted | See the next section |
All 28 tools that create, update, delete, add, remove, set, log, link, send, attach or start something declare workspace as a required schema field. A call without it is rejected by the MCP SDK's input validation before any code runs or any connection is opened:
MCP error -32602: Input validation error: Invalid arguments for tool log_time:
path: ["workspace"], message: "Required"
This holds even if the account has only one workspace. A write never falls back to an implicit, default or previously used workspace. The full list is in Tools.
For the 19 read tools, workspace may be omitted only if exactly one workspace is available, in which case that one is used. With two or more, the call fails and lists the options:
Huly MCP: Multiple workspaces available — pass 'workspace' explicitly (see list_workspaces).
Available: acmeteam (Acme Team), personal (Personal)
With zero workspaces: "No Huly workspaces are accessible with these credentials."
There is no global or session-level workspace state in the server. Using acmeteam in one call does not make it the default for the next call:
list_projects(workspace="acmeteam") → projects of acmeteam
list_projects() → error: Multiple workspaces available …
This is covered by a test (no "last used" fallback).
The first time a workspace is needed:
-
selectWorkspace(slug, 'external')is called on the account-level client. It returns aWorkspaceLoginInfowith the WebSocket endpoint, a workspace token and the workspace UUID.'external'asks for the externally reachable endpoint. - The returned UUID is checked against the discovered one. A mismatch aborts the connection.
- A WebSocket connection is opened with
createClient(endpoint, workspaceToken)from@hcengineering/server-clientand wrapped inTxOperationswith the account's socialId. - A workspace-level account client is created with the workspace token (used for
getWorkspaceMembers()).
The connection is cached by workspace UUID. "acmeteam", "Acme Team" and the UUID therefore share one connection. Further details:
- Concurrency: Simultaneous first calls for the same workspace share one pending connection attempt.
- Failures: A failed connection is not cached, so the next call retries.
-
Lazy opening: Workspaces you never use are never connected.
selectWorkspace()is only called for workspaces that are actually requested.
Loading the Huly model when a connection opens takes a few seconds, which is why connections are kept open for the life of the process rather than opened per call.
Each workspace has its own:
| Per workspace | |
|---|---|
WebSocket connection / TxOperations client |
✔ |
| Workspace token | ✔ |
Workspace UUID (used for /files and datalake requests) |
✔ |
Workspace-level account client (getWorkspaceMembers) |
✔ |
Shared between workspaces: only the account session (account-level client + socialId) and the discovered list.
Every tool resolves its connection from its own workspace argument (getConnection(args.workspace), getWorkspaceInfo(args.workspace)), so a call can never read from one workspace and write to another. The token used for /files downloads and uploads is the token of the workspace named in the call (see Security).
Issue identifiers (PROJ-1), project identifiers, document/comment/attachment/channel IDs: identity is always (workspace, id). Two workspaces can both have a project TSK and an issue TSK-1, and they are unrelated. So:
- Pass the same
workspaceto follow-up calls that you used to get the ID. -
get_issue(workspace="personal", identifier="TSK-1")andget_issue(workspace="acmeteam", identifier="TSK-1")return two different issues.
The server instructions sent to the MCP client state these rules too.
Older versions required HULY_WORKSPACE. It is now optional and has one meaning: restrict this server to that single workspace.
- It is matched like the
workspaceargument (ID, slug or unique name, case-insensitive) against the discovered list. - The server then behaves as if the account had only that workspace:
-
list_workspacesshows only it, - reads may omit
workspace, - other workspaces are rejected as "Unknown workspace".
-
- If it matches no accessible workspace, discovery fails: "HULY_WORKSPACE='…' is not accessible. Available: …".
-
Writes still require the
workspaceargument.HULY_WORKSPACEis a filter, not a default.
Leave it unset to use all workspaces. npm run setup removes it from .env and tells you so.
scripts/import-csv.js, scripts/cleanup-issues.js and scripts/demo.js write data, so they need an explicit workspace: --workspace=<slug|name|id>, or HULY_WORKSPACE as a fallback. See Helper scripts.
On SIGINT/SIGTERM the server calls closeConnection(). It closes every open workspace connection, including ones still being opened, and drops the cached session and workspace list. A tool call that is in flight while shutting down fails with "Huly connections were closed." instead of receiving a closed connection.