@lazco/huly-mcp
v2.0.0
Published
MCP server for Huly: read and write Huly documents, issues, labels, and comments from Claude Desktop, Claude Code, Pi, or any MCP client
Maintainers
Readme
huly-mcp
MCP server for Huly — read and write Huly documents and issues from Claude Desktop, Claude Code, or any MCP client.
Huly does not ship an official MCP server, but it does publish an official SDK,
@hcengineering/api-client,
installable from the public npm registry with no GitHub token required.
This project is a thin Model Context Protocol layer on top of that SDK.
Works with self-hosted Huly. Huly Cloud (huly.app) uses the same API surface and should work,
but has not been verified — see Verification.
- 22 tools — full document CRUD; issue listing with filters, create, update, labels, and comments; and the catalog of statuses, labels, milestones, components, and people
- Zero build step — plain CommonJS, no TypeScript, no bundler
- Actually writes document content — including the collaborative-editor workaround most naive implementations get silently wrong (see Implementation notes)
Contents
- Requirements
- Install
- Configuration
- Verify before connecting
- Authentication: password vs token
- Tools
- Implementation notes
- Verification
- Troubleshooting
- Contributing
- Security
- License
Requirements
- Node.js 20 or newer (verified on v24.12.0)
- A Huly instance — self-hosted, or Huly Cloud
- A Huly account with access to the workspace you want to expose
Install
git clone https://github.com/Lazco-Corporation/huly-mcp.git
cd huly-mcp
npm installOr install the published package:
npm install -g @lazco/huly-mcpThe unscoped name huly-mcp on npm belongs to an unrelated project, so this one
ships under the @lazco scope.
Configuration
The server is configured entirely through environment variables.
| Variable | Required | Description |
|---|---|---|
| HULY_URL | yes | Base URL of your Huly instance, e.g. https://huly.example.com |
| HULY_WORKSPACE | yes | Workspace URL slug (see below) |
| HULY_TOKEN | one of | Auth token |
| HULY_TOKEN_COMMAND | one of | Command that prints the token, e.g. from the macOS Keychain |
| HULY_EMAIL | one of | Account email — requires a password source |
| HULY_PASSWORD | one of | Account password |
| HULY_PASSWORD_COMMAND | one of | Command that prints the password |
| HULY_AUTH_MODE | no | auto (default: token first, password if the token is rejected), token, or password |
| HULY_AUTH_BACKOFF_MS | no | Pause after a rejected password before trying again (default 600000) |
Configure a token, an email with a password, or both. Prefer the _COMMAND
forms so no secret sits in a plain-text config file. See
docs/authentication.md.
| HULY_TIMEOUT_MS | no | Connection timeout in ms (default 30000) |
Finding your workspace slug
Log in to Huly and read the URL:
https://huly.example.com/workbench/my-workspace-slug
^^^^^^^^^^^^^^^^^ this partUse the slug from the URL, not the name shown in the sidebar. They are frequently different, and this is the single most common setup mistake.
Claude Desktop
Edit your Claude Desktop config:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"huly": {
"command": "node",
"args": ["/absolute/path/to/huly-mcp/src/index.js"],
"env": {
"HULY_URL": "https://huly.example.com",
"HULY_WORKSPACE": "my-workspace-slug",
"HULY_EMAIL": "[email protected]",
"HULY_PASSWORD_COMMAND": "security find-generic-password -a [email protected] -s huly-mcp -w"
}
}
}
}This reads the password from the macOS Keychain at sign-in; store it once with
security add-generic-password -a [email protected] -s huly-mcp -w. For Linux, a
token, or a token with password fallback, see
docs/authentication.md.
If node is not on the PATH Claude Desktop sees (common with nvm, fnm, or asdf),
use the absolute path to the Node binary instead, e.g. /Users/you/.local/share/fnm/node-versions/v24.12.0/installation/bin/node.
A copy of this snippet lives in claude_desktop_config.example.json.
Claude Code
claude mcp add huly \
--env HULY_URL=https://huly.example.com \
--env HULY_WORKSPACE=my-workspace-slug \
--env [email protected] \
--env 'HULY_PASSWORD_COMMAND=security find-generic-password -a [email protected] -s huly-mcp -w' \
-- node /absolute/path/to/huly-mcp/src/index.jsVerify before connecting
Run the bundled diagnostic before restarting your MCP client:
HULY_URL=https://huly.example.com \
HULY_WORKSPACE=my-workspace-slug \
[email protected] \
HULY_PASSWORD_COMMAND='security find-generic-password -a [email protected] -s huly-mcp -w' \
npm run doctorIt checks, in order: environment variables and sign-in order → server
config.json → sign-in (including any token-to-password fallback) → data
reads → collaborative content reads. Only restart your client once everything passes.
Authentication: password vs token
Configure either one, or both.
- Token: no lockout risk, but it stops working when Huly revokes it, with no
advance warning. Copy it from a browser request header (DevTools → Network →
any
/_accountsrequest →Authorization: Bearer ...). - Email and password: does not expire, but Huly can lock an account after
repeated failed attempts. A wrong password shows up as
AccountNotFound. - Both: the server uses the token and falls back to the password only if Huly
rejects the token, and only when both belong to the same account.
HULY_AUTH_MODE=tokenorpasswordturns the fallback off.
The server signs in once per session. After a rejected password it pauses
password sign-in for 10 minutes rather than adding to a lockout. huly_whoami
shows which method signed in and whether a fallback happened.
Whichever you use, sign in as a dedicated, least-privilege account rather than a personal OWNER account, because the server acts with all of its permissions.
docs/authentication.md covers the details: storing
secrets in macOS Keychain or libsecret with HULY_PASSWORD_COMMAND /
HULY_TOKEN_COMMAND, the fallback rules, running doctor, reading the
huly_whoami auth output, rotation, and a sign-in troubleshooting table.
Tools
Documents
| Tool | Purpose |
|---|---|
| huly_whoami | Connection check, including how the server signed in — run this first |
| huly_list_teamspaces | List teamspaces |
| huly_list_documents | List documents, filtered by space or parent |
| huly_get_document | Read document content (markdown / html / markup) |
| huly_search_documents | Search titles, optionally including content |
| huly_create_document | Create a document, optionally as a child |
| huly_update_document | Update title or content |
| huly_delete_document | Delete a document (requires confirm=true) |
Issues
| Tool | Purpose |
|---|---|
| huly_list_projects | List Tracker projects |
| huly_list_issues | List issues with filters (status, assignee, label, milestone, component, priority, parent, text) and pagination |
| huly_get_issue | Read an issue: markdown description, and up to 100 comments as markdown with author and id |
| huly_comment_issue | Comment on an issue; the message is markdown and renders formatted in Huly |
| huly_create_issue | Create an issue or sub-issue with description, status, priority, assignee, labels, milestone, component, due date, estimation |
| huly_update_issue | Change any of those fields; null clears assignee, milestone, component, or due date |
| huly_set_issue_labels | Add and remove labels by title; createMissing creates unknown ones |
| huly_update_comment | Replace a comment's markdown |
| huly_delete_comment | Delete a comment (requires confirm=true) |
| huly_list_statuses | Statuses of a project in workflow order, with category and the default |
| huly_list_labels | Labels that can be applied to issues, optionally filtered by title |
| huly_list_milestones | Milestones of a project with status and target date |
| huly_list_components | Components of a project with lead and a markdown description excerpt |
| huly_list_people | Active members who can be assigned, with email, optionally filtered |
huly_get_issue returns each comment as
{ id, author, message, createdOn }, oldest first, where message is markdown
and author is the writer's display name (null if it cannot be resolved).
commentCount is the total number of comments; commentsTruncated is true
when there are more than the 100 returned.
Listing issues
huly_list_issues returns issues newest-modified first. Filters combine with AND,
and a filter given a list matches any of its values:
| Filter | Accepts |
|---|---|
| projectId | Project id, identifier, or name |
| status | Status name(s), e.g. "In Progress"; matched within the project when one is given |
| statusCategory | backlog, todo, in-progress, done, canceled |
| assignee | Person name (either order), email, me, or none |
| label | Label title(s) |
| milestone, component | Label(s) within the project, or none |
| priority | Urgent, High, Medium, Low, No priority, or 0-4 |
| parent | Issue identifier; returns its direct sub-issues |
| text | Case-insensitive text inside the title |
none cannot be mixed with other values in one filter; make two calls instead.
Each row carries identifier, title, status, priority and priorityName,
assignee, labels, milestone, component, parent, dueDate, projectId,
and modifiedOn. The response has total (issues matching the filters) and
nextCursor; pass it back as cursor with the same filters to get the next
page. It is null on the last page. A cursor used with different filters is
rejected.
huly_list_issues { "assignee": "me", "statusCategory": ["todo", "in-progress"] }
huly_list_issues { "projectId": "CLD", "label": "backend", "limit": 20 }
huly_list_issues { "parent": "CLD-142" }Writing issues
huly_create_issue { "projectId": "CLD", "title": "Redis has no persistence",
"description": "## Context\n...", "priority": "High",
"assignee": "me", "labels": ["backend"], "parent": "CLD-142" }
-> { "created": true, "identifier": "CLD-161", ... }
huly_update_issue { "identifier": "CLD-161", "status": "In Progress" }
huly_set_issue_labels { "identifier": "CLD-161", "add": ["infra"], "remove": ["backend"] }Every reference is resolved before anything is written, so a typo in a label or
status never leaves a half-created issue or uses up an issue number. Use the
catalog tools (huly_list_statuses, huly_list_labels, ...) to see valid values.
One write at a time. The server sends at most one write to Huly at a time;
write calls that arrive in parallel wait in a queue, in order. Reads are not
queued. On 2026-10-08, ten concurrent issue creations against one project
stalled the Huly 0.7.426 transactor for every user until it was restarted, so
this is deliberate. The queue is per server process: several MCP clients, each
running its own server, are not coordinated, so avoid bulk writes from many
clients at once. Set HULY_DEBUG_WRITES=1 to log each write's start and end to
stderr.
Referring to things
Every parameter that takes an id also accepts the name people use:
| Kind | Accepted | Example |
|---|---|---|
| Project (projectId) | id, identifier, or name | CLD, lazco cloud |
| Teamspace (teamspaceId) | id or name | Lazco Cloud |
| Document (documentId, parentId) | id or exact title; with a teamspaceId on the same call, only that teamspace is searched | Release checklist |
| Issue (issueId, identifier) | id or identifier | CLD-142, cld-142 |
Names match exactly, ignoring case and surrounding spaces. A tool never picks an entity by a partial or fuzzy match:
huly_list_issues { "projectId": "Lazco" }
-> Error: Project not found: Lazco. Did you mean: "CLD (Lazco Cloud)", "LAZGO (Lazgo!)"?When a name matches more than one entity, nothing happens and the error lists the candidates:
huly_get_document { "documentId": "Roadmap" }
-> Error: Document "Roadmap" is ambiguous (2 matches): 6a94... (teamspace "Engineering"),
6a95... (teamspace "Marketing"). Pass the id instead, or narrow it with teamspaceId.Implementation notes
These are the non-obvious parts. If you are extending the server, read this section first.
Document content is not an ordinary field. Huly's Document.content is a reference to a
collaborative-editing blob (MarkupBlobRef), so you cannot write a string to it with
updateDoc. You must go through fetchMarkup / uploadMarkup, which talk to
COLLABORATOR_URL. Creating a document with content is therefore two steps: create the
document to get an id, then upload the markup and write the reference back.
Node needs an explicit WebSocket implementation. connect() runs over WebSocket and Node
has no global WebSocket, so NodeWebSocketFactory must be passed in (already handled). If
you front Huly with a reverse proxy, it must forward Upgrade / Connection headers.
Updating content must go through the collaborator — a trap worth knowing. api-client's
uploadMarkup actually calls the collaborator's createContent, which means create a new
blob. Used against a document that already has collaborative state, the server keeps its
existing YDoc and silently discards the new blob — the API reports success and the content
does not change. The correct call is the collaborator's updateContent (updateMarkup),
which api-client does not expose, so src/huly.js builds its own collaborator client.
As a result huly_update_document branches: uploadMarkup when the document has no content
yet, updateMarkup when it does. This bug is fixed and covered by end-to-end testing.
Content updates replace the whole body. The content argument to huly_update_document
replaces the entire document. To append, read with huly_get_document, merge, then write back.
Comments are markup too. A comment's message is stored as a markup JSON string,
not plain text. Writing raw text into it makes markdown syntax show literally, and
reading it raw hands the model a ProseMirror tree. Both directions go through the same
markdown converter as document content (src/markdown.js).
Markdown tables become HTML tables. Write a table and read it back and you get <table>
markup rather than the original |---| syntax. The content is correct and renders correctly in
the Huly UI, but the round trip is not byte-symmetrical.
CommonJS, not TypeScript. @hcengineering/[email protected] declares types/index.d.ts
in its package.json, but that directory is not actually published with the package, so a
TypeScript build fails immediately. Hence plain JavaScript.
Verification
Unit tests (npm test) cover configuration, sign-in decisions, markdown
conversion, name resolution, pagination, write validation, and the write queue.
Live checks run against a self-hosted Huly 0.7.426 in a private sandbox project
and teamspace, one write at a time: document create and repeated updates of
existing content; issue create (minimal, fully specified, sub-issue), ten
parallel creates through the queue with distinct identifiers, rejected
references consuming no issue number; issue update including replacing an
existing description and clearing fields; label add, remove, no-op, and
createMissing; markdown comments with author, edit, and guarded delete; and an
end-to-end run of a review-findings workflow (parent issue, five sub-issues with
component and labels, status changes, comments, filtered listing).
Verified against: Huly 0.7.426, api-client 0.7.423, MCP SDK 1.30.0, Node v24.12.0.
When your Huly instance updates, and especially if api-client takes a major version bump,
update this project's dependencies alongside it.
Troubleshooting
| Symptom | Fix |
|---|---|
| Workspace not found | HULY_WORKSPACE is set to the display name; use the URL slug instead |
| Authentication failed / AccountNotFound | Wrong email or password (Huly does not say which), or an expired token. See sign-in troubleshooting |
| Connection timeout | Reverse proxy is not forwarding WebSocket upgrades |
| Tools not visible in the client | Fully quit and reopen Claude Desktop — closing the window is not enough |
| Content reads fail | Check that COLLABORATOR_URL in the server's config.json is not empty |
Claude Desktop MCP log:
# macOS
tail -f ~/Library/Logs/Claude/mcp-server-huly.logLines like no document found, failed to apply model transaction, skipping come from the Huly
server's model sync and are harmless noise — they do not indicate a failed tool call.
Contributing
Issues and pull requests are welcome. Start with CONTRIBUTING.md — it covers the development setup, the unit-test and live-verification split, and the two conventions that silently break things when violated.
Participation is governed by our Code of Conduct.
The short version:
- Run
npm run doctoragainst your own Huly instance before and after your change. - Keep the code plain CommonJS — see Implementation notes for why.
- Never write to stdout from
src/; it carries the JSON-RPC stream. - If you touch content read/write paths, test against a document that already has collaborative content, not just a fresh one. That is where the silent-failure trap lives.
Security
Do not report security issues in a public issue. See SECURITY.md for private reporting, and for operator notes worth reading before you deploy — in particular that this server acts with your full Huly account permissions and adds no authorisation layer of its own.
License
AGPL-3.0-or-later. See LICENSE.
Copyright (C) 2026 Lazco Corporation.
The AGPL's network clause applies: if you modify this server and let others use it over a network, you must offer them the modified source. Running it unmodified, or using it privately, carries no such obligation.
