Skip to content

Multi Workspace

JoKeks2023 edited this page Sep 28, 2026 · 1 revision

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.

Overview

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

One account login

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-client created 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.

Discovery: getUserWorkspaces()

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.

Choosing a workspace: the workspace argument

Every tool except list_workspaces has a workspace argument. It accepts, case-insensitively:

  1. the ID (uuid),
  2. the slug (url),
  3. 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

Rules: reads vs. writes

Tools that change data: workspace is required

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.

Tools that only read: workspace is optional, but only when unambiguous

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

No current workspace, no "last used" workspace

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

One cached connection per workspace

The first time a workspace is needed:

  1. selectWorkspace(slug, 'external') is called on the account-level client. It returns a WorkspaceLoginInfo with the WebSocket endpoint, a workspace token and the workspace UUID. 'external' asks for the externally reachable endpoint.
  2. The returned UUID is checked against the discovered one. A mismatch aborts the connection.
  3. A WebSocket connection is opened with createClient(endpoint, workspaceToken) from @hcengineering/server-client and wrapped in TxOperations with the account's socialId.
  4. 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.

Isolation

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

Entity IDs are only unique within a workspace

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 workspace to follow-up calls that you used to get the ID.
  • get_issue(workspace="personal", identifier="TSK-1") and get_issue(workspace="acmeteam", identifier="TSK-1") return two different issues.

The server instructions sent to the MCP client state these rules too.

Legacy HULY_WORKSPACE

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 workspace argument (ID, slug or unique name, case-insensitive) against the discovered list.
  • The server then behaves as if the account had only that workspace:
    • list_workspaces shows 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 workspace argument. HULY_WORKSPACE is a filter, not a default.

Leave it unset to use all workspaces. npm run setup removes it from .env and tells you so.

Helper scripts

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.

Shutdown

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.

Clone this wiki locally