Repository navigation
feat(tracker): GitHub Projects feature parity — custom fields, saved views, filter grammar, roadmap, insights, workflows - #48
Conversation
Exposes a Huly workspace to AI agents over MCP Streamable HTTP, as a regular pod in the monorepo. It ships in the same Docker release as everything else and is version-locked to the Huly API it talks to, because it consumes @hcengineering/* as workspace dependencies. Auth has two modes behind one Authenticator interface: - configured (self-host): HULY_TOKEN or HULY_EMAIL+HULY_PASSWORD on the pod, so MCP clients need no Huly credential at all. This is what makes the endpoint usable from Claude Desktop. - perRequest (multi-tenant): the client presents its own Huly API token. Optional decorators: MCP_ALLOWED_TOKENS allowlist, and an MCP_READONLY clamp applied outermost so it cannot be bypassed by a token flag. Security: - registers setApiTokenRevocationChecker at boot; without it verifyToken silently accepts revoked API tokens - sessions are pinned to the account+workspace that created them, so a leaked Mcp-Session-Id is useless and a token swap returns 403 - guest tokens rejected, read-only tokens blocked from write tools - never uses the system account, so writes stay permission-checked and attributed to the caller - per-client rate limiting, since standalone pods get none from the platform - refuses to boot when SECRET is unset, because server-token would otherwise verify every token against the literal string "secret" No @modelcontextprotocol/sdk dependency: the server side of MCP is JSON-RPC 2.0 plus a small envelope, src/mcp/ is transport- and platform-agnostic and unit tested, and the repo has no external AI SDK or zod today. Tools: huly_search, huly_list_projects, huly_get_project, huly_list_issues, huly_get_issue, huly_list_issue_statuses, huly_create_issue, huly_update_issue, huly_add_issue_comment, huly_create_milestone, huly_list_milestones, huly_list_tasks, huly_find_people, huly_create_person, huly_list_spaces, huly_list_drives, huly_list_documents, huly_get_document. Not modelled on ZubeidHendricks/huly-mcp: that project is a 6-commit demo with hardcoded fixtures, a custom JSON-RPC dialect rather than MCP, no auth, and no LICENSE file. Only its action inventory was used, as a feature checklist. Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
Body-parser rejections fell through errorHandler and surfaced as 500. Adds regression tests, a README smoke-test section and an agentlog entry. Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
A live run against a real workspace (huly v0.7.432 images) exposed defects that typechecking and mocked unit tests could not: - configured auth: an account-level token or email/password login is not bound to a workspace; now exchanged via selectWorkspace(HULY_WORKSPACE) - create_issue: createDoc is illegal for AttachedDoc; use addCollection, number via the atomic project sequence $inc (fixes the max+1 race), and derive kind/default status from the project task type - issue description was silently dropped; now stored through the collaborator service (COLLABORATOR_URL) and refused explicitly when it is not configured - add_issue_comment: same AttachedDoc error, and the body must be rich text - list_projects returned every tracker project twice and clobbered the issue key prefix, because task.class.Project is the base of tracker.class.Project - find_people looked identities up by the wrong field and filtered after the page limit; matching now happens in the database - list_issues/list_documents search used $regex (unsupported) and includeDone compared against a category name that does not exist - get_issue/get_document returned raw ProseMirror JSON; now Markdown - tools/list now hides write tools from read-only identities - SECRET=secret is refused unless MCP_ALLOW_DEFAULT_SECRET=true, which the dev compose sets so the stack still starts Adds 28 unit tests covering each of these. All 18 tools now pass a 38-check end-to-end run against a live stack, both from source and from the built image. Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
… args - add huly_list_components, huly_create_component, huly_update_milestone - huly_create_issue: parentIssueId (sub-issues, ancestor chain nearest first) and componentId; refusals happen before the project sequence is incremented - huly_update_issue: componentId - fix milestones: create/list used name/dueDate/done/project, but the model has label/targetDate/startDate/status and lives in the project space - fix "null clears a field" being rejected by the validator: JsonSchema.type may now be an array, add nullableStringProp - fix huly_get_issue returning no subtasks: sub-issues are attached to their parent, there is no `parent` field Verified live against a local Huly stack (20 end-to-end checks) and by 111 unit tests. Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
…ervice - huly_get_workspace, huly_list_members (read) - huly_update_workspace_name, huly_update_workspace_guest_settings, huly_set_member_role, huly_remove_member, huly_invite_member, huly_create_invite_link (write) - calls go through a narrow AccountApi facade on the session, made with the caller's own workspace token so the account service enforces the role rules; Forbidden is reported as a sentence naming the action - member names come from workspace Person docs (the account service's person lookup is service-only) - deliberately not exposed: workspace deletion, API token minting/revoking, account merge/delete, password changes Verified live with an Owner and a plain User against a local stack (14 checks) and by 124 unit tests. Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
- huly_list_classes: discover queryable classes, filter by id substring, ancestor or kind - huly_describe_class: fields, types, required/custom flags and collections - huly_find: query any class with filters, sort, field projection and a total count; output is cut to 60k chars with a truncated flag - huly_get_doc: fetch one document with rich-text fields as Markdown Classes come from the client's Hierarchy, so there is no hard-coded list, and every call runs as the caller so the transactor limits what is returned. Verified live (17 checks, including a plain user seeing fewer issues than the owner) and by 139 unit tests. Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
Adds huly_create_document / huly_update_document for pages, and huly_create_doc / huly_update_doc / huly_delete_doc for any class that has a write profile. A write is only as good as the bookkeeping around it: the web client initialises counters, derives kinds and ranks, and detaches references before a delete. Letting an agent write an arbitrary class would skip all of that and leave documents the UI misrenders. So a class is writable only when a profile declares it, and every profile states the exact writable fields, which numeric enums are exposed by name, and which space the document belongs in. Fields outside a profile are refused rather than passed through. Deletes go through caller-rights, which mirrors the ownership rule the web app applies in the browser: the transactor would let any workspace member delete or rename a space someone else owns, so the tools require a workspace Owner or the creator of the document. This keeps an agent's reach no wider than the person using the UI. All five tools are readOnly: false, so MCP_READONLY and read-only tokens block them through the existing registry gate. Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
…ndings Adds the @hcengineering/tags and @hcengineering/rank importers that src/tools/write-profiles.ts needs. Without this every developer's rush install would produce lockfile drift. Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
Review of Platform-Collective#43 raised three blockers, a licensing problem and some hygiene items. This closes them. Configured mode now requires a credential on the wire. It authenticates nobody, so the pod's own account was reachable by anyone who could open the port: loadConfig refuses to boot unless MCP_ALLOWED_TOKENS is set or HOST is loopback. The allowlist runs before the configured authenticator, so a rejected token never triggers a login attempt, and an absent one is reported as `missing` so the client gets a WWW-Authenticate challenge. Origin is validated before the cors() middleware. An unknown browser origin gets 403 instead of a passing preflight, which closes the DNS-rebinding path through the dev stack's published 4090. Requests with no Origin header are untouched, so Claude Desktop and other non-browser clients behave exactly as before. An empty list fails closed. Read-only is decided per request rather than read from the cache. The client cache was keyed by account:workspace and kept whichever identity arrived first, so a full-access token could lend its workspaceToken and permissions to a read-only token for the same workspace. The key now includes the read-only flag, toolContext takes the caller's identity explicitly, and a session can no longer be reused across privilege classes. Also: honour TRUST_PROXY so a reverse proxy does not collapse every client into one rate-limit bucket, correct the rate limiter comment that claimed account-based keying, replace the invalid copyright notice with the SPDX identifier used on develop, drop the unused morgan dependency and the svelte field, merge the duplicate HULY_WORKSPACE documentation, remove the scratch log from the PR, and revert the unrelated language change to the agent instruction files. Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
Issue creation: - derive the rank with makeRank instead of writing an empty string, so a new issue lands at the end of the project's order rather than relying on the rank sentinel being rewritten downstream - validate the milestone and the assignee against the project and the workspace, the way the component id was already validated - reserve the issue number before the description blob is written, so a failed reservation cannot leave an orphaned blob behind Generic writes: - page the detach sweep until no issue matches the query, and refuse the delete if it cannot finish, instead of stopping at 1000 and leaving dangling references - union caller-supplied members and owners with the creator on teamspace create, so the creator can never be locked out of a space they made - type-check pushed and pulled items against the model's element type Endpoints: - serve /api/v1/statistics only when MCP_STATS=true, defaulting to off - drop authMode, readOnly and sessions from /api/v1/health and the landing page, which answer without a credential Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ction Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nges tracking Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… config; drop stray d.ts
Group by a field and then by another field, per view, with the levels bounded by the layout and the collapsed state kept per viewer and per saved view. - Pure builders with jest tests next to them: grouping/nested.ts builds the nested buckets (any number of levels, explicit option order, "No <field>" last on every level, includeEmpty per level, stable path ids, flatten/visible/summarize/collapse helpers) and grouping/levels.ts resolves the levels (caps, de-duplication, skipping the board column field and deleted custom fields, "No grouping" ends the list). view-resources/nestedGroups.ts holds the shared list rows and the empty-category rules. - Table: "Group by" / "Then by" rows (view.ThenBy in all 14 locales). Changing a level keeps the levels below it and drops only a key that became a duplicate; fixes an off-by-one that let the popup grow a fourth row in one session. - Board: swimlanes in two levels (lane, then sub-lane). A drop on a sub-lane writes column + lane + sub-lane in one update. - Roadmap: rows in two levels, field sums on every level. Replaces the single level roadmap/grouping.ts. - View export lists the sub-lane keys between the lane and column keys. - Model: groupDepth 3 for the table, 2 for the board and the roadmap. Stored viewlet docs are not updated by createDoc, so the upgrade step set-nested-group-depth of model-tracker applies it to the existing viewlets. A stored single groupBy behaves exactly as before and the one-level list code path is unchanged, so every other list and the sub-issues of an issue are unaffected.
ArtyomSavchenko
left a comment
There was a problem hiding this comment.
Review — tracker ↔ GitHub Projects parity
Scope: tracker commits only (e43a9c6d..5f487eb4, 21 commits, 363 files, ~+55k). MCP commits are ignored — they're covered by a separate PR.
Verdict: not mergeable as is. The pure logic (filter grammar, layout maths, SSRF guard) is well written and well tested. The blockers are a red CI, server triggers that put data and performance at risk, and the plan's own note (plan.md §Phase 10, "Not verified") that nothing was run against a real server or in a browser.
Blockers
-
CI is red, and it is a regression. On the PR head,
uitest,uitest-pgandformattingfail, while all three are green ondevelop(fa7e2497).formattingfails on real eslint errors, not just prettier:plugins/tracker/src/projectStatus.ts:71,projectCopy.ts:156,iteration.ts:123(number in a conditional),__tests__/workflow.test.ts:100-128,__tests__/project-status.test.ts:49. "No formatter was run" doesn't cover these. -
OnProjectItemLimitsilently deletes newly created issues (server-plugins/tracker-resources/src/index.ts:246). Existing installations may already have projects with more than 50,000 items (imports, GitHub sync). After deploy, every new issue in such a project is removed right after it is created. There is no user-facing error, the parent'ssubIssuescounter is not decremented, the sequence number is burned, and notifications have already gone out. On top of that, every issue creation now runsfindAll(..., { total: true }), a COUNT over the project on a hot path. The limit should reject the create before it is written, or at least sit behind a flag. Compensating removal after the fact isn't acceptable. -
Lost updates on
customFields. All values live in oneRecord, and every write replaces the whole record from the snapshot the writer saw. This applies tosetIssueCustomFieldValue(projectFields/actions.ts:28), bulk edit, board move and roadmap reschedule, and on the server toOnIssueWorkflow,OnProjectFieldRemoveandOnIterationRemove. Two users editing different fields of the same issue, or a user edit racing a workflow setting Status, will drop one of the changes. The plan doesn't discuss this. Please write per key (customFields.<key>) if the adapters support it, or at least merge against the fresh document on the server side. -
Hand-edited
pnpm-lock.yaml. The plan says "edited by hand… runrush updateto confirm". It needs a realrush updatebefore merge.
Important
- A webhook secret can be overridden by another user.
newestSecret(webhook/trigger.ts:193) takes the newestProjectWebhookSecretbywebhook: idand never checks who created it. Secrets live in the author's personal space, so any workspace user who knows a webhook id can create a newer one. They then know the signing key and can forge deliveries to the receiver. Please restrict this to secrets whosecreatedByis a project member or owner (ideally whoever last modified the webhook). - Webhook permissions are broad. Any member with write access can configure a webhook (
plan.md:980). GitHub restricts this to admins. A webhook keeps exporting every issue change after its creator has been removed from the project. Consider limiting configuration to project owners. - Drafts are real
Issuedocs, so they show up in everything that queriestracker.class.Issue. Notifications, search and component/milestone lists are acknowledged in the plan (plan.md:1172). Unverified, and important:services/github/pod-githubhas no knowledge ofisDrafton tracker issues, so a draft in a GitHub-connected project will likely be synced as a real GitHub issue with number 0. This needs checking before merge. - Heavy synchronous triggers.
OnProjectFieldRemoveandOnIterationRemoveload every issue in the project (up to 50k) with no projection and nocustomFields.<key> $existsfilter, then emit up to 50k update txes in one sync batch.replayAround(history.ts:25) reads the object's full tx history on every status change (OnIssueWorkflow, sync) and on every watched edit (webhooks).- Please add the filter and projection, and move these to async where possible.
- A server package now depends on a UI package.
server-tracker-resourcesgained a runtime dependency on@hcengineering/view-resourcesplus a deep import ofsrc/filter/grammar(workflow/filter.ts:19). The grammar should move to a pure package (@hcengineering/viewor a new one). Server bundling with this import is listed as not verified. - A global
clientViewExtensionin the sharedList. It's one app-wide writable, set byIssuesView(:863) and cleared inonDestroy(:876). With two lists on screen, or a different mount/destroy order during navigation, the tracker extension can leak into other plugins' lists or be cleared under a liveIssuesView. Passing it via context or props would be safer.
Minor
- Build artifacts are committed: 8
*.d.ts.mapfiles inplugins/view-resources/src/filter/grammar/, plus a deadplugins/tracker-resources/src/iterations/IterationPresenter.svelte.x. isAutomationAuthor(..., core.account.System)skips every System-authored change, including integrations and migrations. As a result, "Done on close" won't fire when GitHub sync closes an issue. If that's intended, please document it.IssuesView.sveltegrows by ~1000 lines andRoadmapView.svelteis 1445 lines. "Mostly presentational wiring" no longer really holds.- PR size: 55k lines across 14 phases is hard to review meaningfully. Suggested split: (1) custom fields + saved views + filter grammar, (2) board/roadmap/iterations, (3) workflows + webhooks (server), (4) calendar/workload/nested grouping.
Done well
- The webhook SSRF guard is thorough: the address is pinned at socket lookup so DNS rebinding can't get through, IPv4-mapped/NAT64/6to4/metadata addresses are covered, redirects aren't followed, and the response body isn't read.
- The filter compiler splits server and client parts correctly, and LIKE metacharacters are left to client evaluation instead of being escaped.
isDraft: { $ne: true }is safe on Postgres, because the adapter's$nehandles missing fields.- Draft conversion uses
apply+match. - The
set-nested-group-depthmigration is idempotent.
Generated by Claude Code
What
Brings
/tracker/to GitHub Projects feature parity, plus four extras GitHub does not have(Calendar, Workload, nested grouping, and — in a later phase — an automation rule builder).
Design doc with the full gap analysis, the seven architecture decisions, the risk table and
per-phase implementation notes:
docs/tracker-projects-parity/plan.md.This branch was created off
feat/mcp-http-server, so the diff againstdevelopalso contains11 MCP commits (
pods/mcp,rush.json,dev/docker-compose.yaml,common/config).Those have no PR of their own yet and are not part of this effort.
Reviewing this PR? Either
git log d1a668cb2..HEAD, orScope corrections (verified against GitHub docs + live GraphQL schema)
Worth stating up front, because the naive reading of the feature list is wrong:
Current iteration,Next iteration,Prioritized backlog,In review,My itemsare GitHub defaults.tsv. 6 chart layouts, no pie.Shipped
Parity phases 0–11
ProjectFielddoc +Issue.customFieldsrecord + registry + CRUD UI + per-type editors + columns/filter/sort/group-by.+ New view, per-view columns/card fields/layout, unsaved-changes dot, rename/duplicate/delete/reorder, singlegetEffectiveViewConfig()source of truth.field:has:no:is:@me@today@current@next@previous, rangesa..b, wildcards,AND/OR+ parentheses. One tokenizer, two consumers (server query + client eval for un-indexable custom fields).Shift+arrows, copy/paste, fill handle,Deleteto clear, undo, row + column drag-reorder, row height.@current/@next/@previous, board column field, rollups, bulk move.Extra phases 12–14 (reinstated by the author — GitHub has none of these)
Phase 15 (automation rule builder) is not in this PR — the field-change webhook from phase 10
is the parity-faithful route and it lands first.
The three decisions worth reviewing hardest
Custom fields are a record, not attributes. The model is
TxModel-fixed:core.class.Attributedocs are generated by the model build and pushed at server upgrade, so a user-defined field can
never be a real attribute. Values therefore live in a
Record<string, any>onIssueand custom-fieldfilter/sort/group run client-side behind a configurable
TRACKER_CUSTOM_FIELD_SCAN_LIMIT, whichdisables the operation with a visible error rather than silently truncating.
Alternatives considered and rejected are in plan §3 D1.
One filter tokenizer, two consumers. Server-side compiles the grammar to
DocumentQuery;client-side evaluates the same tokens for custom fields the server cannot index. Sharing the
tokenizer is what stops the two from drifting.
Two sources of truth for view config, deliberately resolved.
ViewOptionslives inlocalStorage;FilteredViewis a server doc. Rule: an active saved view wins, localStorage isthe fallback for ad-hoc state, and everything reads through one accessor.
Validation
Run locally against the 5 changed packages:
1,533 tests, 0 failures. Locale parity is enforced by the existing
plugins/view-assets/src/__tests__/lang.test.ts, which fails the build if a newIntlStringmisses alocale. No formatter was run (repo rule — it can corrupt files).
Known limitations (intentional, not oversights)
Replaying
DOMAIN_TXwould work but degrade and pretend to be correct, so it was cut instead.Recorded as D6 in the plan.
still the flat
members: AccountUuid[].Review guidance
The interesting parts are the pure logic, which is small and heavily tested:
plugins/view-resources/src/filter/grammar/— tokenizer, parser, compiler, evaluatorplugins/view-resources/src/nestedGroups.ts— group-by rows, empty-category rulesplugins/tracker-resources/src/grouping/— nested bucket builder + level resolutionplugins/tracker-resources/src/iterations/,roadmap/,board/,insights/— layout mathsserver-plugins/tracker-resources/src/workflow/— automation loop guardsThe Svelte components are large but mostly presentational wiring over the above.
Model changes ship with migration steps in
models/tracker/src/migration.ts; note thatbuilder.createDocdoes not update already-stored viewlet docs, soset-nested-group-depthexists to patch depth onto the pre-existingIssueList/IssueKanban/IssueRoadmapviewlets.