From 4f1fbef10a477b711b2a6ec67942e9f671e658df Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:41:58 +0530 Subject: [PATCH 01/32] feat(mcp): add pod-mcp, a Model Context Protocol server for Huly 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> --- ARCHITECTURE_OVERVIEW.md | 12 + common/config/rush/pnpm-lock.yaml | 121 ++++ common/scripts/docker.sh | 1 + dev/docker-compose.yaml | 16 + docs/agentlog.md | 21 + pods/mcp/.eslintrc.js | 7 + pods/mcp/Dockerfile | 9 + pods/mcp/README.md | 143 +++++ pods/mcp/config/rig.json | 5 + pods/mcp/jest.config.js | 7 + pods/mcp/package.json | 78 +++ pods/mcp/src/__tests__/test-doubles.ts | 86 +++ pods/mcp/src/auth/__tests__/auth.test.ts | 84 +++ pods/mcp/src/auth/authenticator-factory.ts | 94 ++++ pods/mcp/src/auth/authenticator.ts | 68 +++ pods/mcp/src/auth/configured-authenticator.ts | 145 +++++ pods/mcp/src/auth/http-types.ts | 22 + pods/mcp/src/auth/huly-token-authenticator.ts | 169 ++++++ pods/mcp/src/auth/token-extractor.ts | 39 ++ pods/mcp/src/config.ts | 155 +++++ pods/mcp/src/error.ts | 24 + pods/mcp/src/index.ts | 136 +++++ pods/mcp/src/mcp/__tests__/dispatcher.test.ts | 185 ++++++ pods/mcp/src/mcp/__tests__/protocol.test.ts | 93 +++ .../__tests__/registry-and-session.test.ts | 168 ++++++ pods/mcp/src/mcp/__tests__/validation.test.ts | 117 ++++ pods/mcp/src/mcp/dispatcher.ts | 191 +++++++ pods/mcp/src/mcp/protocol.ts | 170 ++++++ pods/mcp/src/mcp/schema.ts | 91 +++ pods/mcp/src/mcp/session.ts | 155 +++++ pods/mcp/src/mcp/streamable-http.ts | 258 +++++++++ pods/mcp/src/mcp/tool.ts | 146 +++++ pods/mcp/src/mcp/validation.ts | 200 +++++++ .../middleware/__tests__/rate-limiter.test.ts | 72 +++ pods/mcp/src/middleware/index.ts | 114 ++++ pods/mcp/src/middleware/rate-limiter.ts | 78 +++ pods/mcp/src/platform/markup-reader.ts | 30 + .../src/platform/workspace-client-provider.ts | 182 ++++++ pods/mcp/src/server.ts | 172 ++++++ pods/mcp/src/tools/document-tools.ts | 140 +++++ pods/mcp/src/tools/issue-tools.ts | 532 ++++++++++++++++++ pods/mcp/src/tools/people-tools.ts | 294 ++++++++++ pods/mcp/src/tools/project-tools.ts | 179 ++++++ pods/mcp/src/tools/register.ts | 57 ++ pods/mcp/src/tools/search-tool.ts | 102 ++++ pods/mcp/src/tools/shared.ts | 166 ++++++ pods/mcp/tsconfig.json | 12 + rush.json | 5 + 48 files changed, 5351 insertions(+) create mode 100644 docs/agentlog.md create mode 100644 pods/mcp/.eslintrc.js create mode 100644 pods/mcp/Dockerfile create mode 100644 pods/mcp/README.md create mode 100644 pods/mcp/config/rig.json create mode 100644 pods/mcp/jest.config.js create mode 100644 pods/mcp/package.json create mode 100644 pods/mcp/src/__tests__/test-doubles.ts create mode 100644 pods/mcp/src/auth/__tests__/auth.test.ts create mode 100644 pods/mcp/src/auth/authenticator-factory.ts create mode 100644 pods/mcp/src/auth/authenticator.ts create mode 100644 pods/mcp/src/auth/configured-authenticator.ts create mode 100644 pods/mcp/src/auth/http-types.ts create mode 100644 pods/mcp/src/auth/huly-token-authenticator.ts create mode 100644 pods/mcp/src/auth/token-extractor.ts create mode 100644 pods/mcp/src/config.ts create mode 100644 pods/mcp/src/error.ts create mode 100644 pods/mcp/src/index.ts create mode 100644 pods/mcp/src/mcp/__tests__/dispatcher.test.ts create mode 100644 pods/mcp/src/mcp/__tests__/protocol.test.ts create mode 100644 pods/mcp/src/mcp/__tests__/registry-and-session.test.ts create mode 100644 pods/mcp/src/mcp/__tests__/validation.test.ts create mode 100644 pods/mcp/src/mcp/dispatcher.ts create mode 100644 pods/mcp/src/mcp/protocol.ts create mode 100644 pods/mcp/src/mcp/schema.ts create mode 100644 pods/mcp/src/mcp/session.ts create mode 100644 pods/mcp/src/mcp/streamable-http.ts create mode 100644 pods/mcp/src/mcp/tool.ts create mode 100644 pods/mcp/src/mcp/validation.ts create mode 100644 pods/mcp/src/middleware/__tests__/rate-limiter.test.ts create mode 100644 pods/mcp/src/middleware/index.ts create mode 100644 pods/mcp/src/middleware/rate-limiter.ts create mode 100644 pods/mcp/src/platform/markup-reader.ts create mode 100644 pods/mcp/src/platform/workspace-client-provider.ts create mode 100644 pods/mcp/src/server.ts create mode 100644 pods/mcp/src/tools/document-tools.ts create mode 100644 pods/mcp/src/tools/issue-tools.ts create mode 100644 pods/mcp/src/tools/people-tools.ts create mode 100644 pods/mcp/src/tools/project-tools.ts create mode 100644 pods/mcp/src/tools/register.ts create mode 100644 pods/mcp/src/tools/search-tool.ts create mode 100644 pods/mcp/src/tools/shared.ts create mode 100644 pods/mcp/tsconfig.json diff --git a/ARCHITECTURE_OVERVIEW.md b/ARCHITECTURE_OVERVIEW.md index a59098e5d8..396d976c8c 100644 --- a/ARCHITECTURE_OVERVIEW.md +++ b/ARCHITECTURE_OVERVIEW.md @@ -390,6 +390,8 @@ sequenceDiagram | analytics | platformcollective/analytics-collector | 4017 | Analytics collection | account, stats | | process | platformcollective/process | - | Workflow automation | redpanda, account | | rating | platformcollective/rating | - | Content rating | cockroach, redpanda, account | +| **AI** | | | | | +| mcp | platformcollective/mcp | 4090 | Model Context Protocol server (Streamable HTTP) | account, transactor, stats | | **Backup** | | | | | | backup | platformcollective/backup | - | Automated backup | cockroach, minio, account | | backup-api | platformcollective/backup-api | 4039 | Backup REST API | minio, account | @@ -433,6 +435,16 @@ sequenceDiagram - `QUEUE_CONFIG`: `cockroach|http://redpanda:9092` - Region-based event routing - `HULY_KAFKA_BOOTSTRAP`: `redpanda:9092` - Kafka bootstrap servers +### MCP Configuration (mcp only) +- `HULY_TOKEN`: Huly API token for the pod's own identity. Setting it selects self-hosted mode, where MCP clients need no Huly credential. +- `HULY_EMAIL` / `HULY_PASSWORD`: Login used when no `HULY_TOKEN` is set +- `HULY_WORKSPACE`: Pin the configured account to a single workspace +- `MCP_READONLY`: `false` - Refuse every write tool +- `MCP_ALLOWED_TOKENS`: Comma-separated token allowlist for multi-tenant mode +- `MCP_RATE_LIMIT` / `MCP_RATE_WINDOW_MS`: `300` / `60000` - Per-client rate limit + +See `pods/mcp/README.md` for the full list. + ### Service URLs (Internal) - `ACCOUNTS_URL`: `http://huly.local:3000` - `TRANSACTOR_URL`: `ws://huly.local:3332` diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index b091421b53..b859ca389e 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -31043,6 +31043,127 @@ importers: specifier: ^5.9.3 version: 5.9.3 + ../../pods/mcp: + dependencies: + '@hcengineering/account-client': + specifier: workspace:^0.7.25 + version: link:../../foundations/core/packages/account-client + '@hcengineering/analytics': + specifier: workspace:^0.7.19 + version: link:../../foundations/core/packages/analytics + '@hcengineering/analytics-service': + specifier: workspace:^0.7.19 + version: link:../../foundations/core/packages/analytics-service + '@hcengineering/api-client': + specifier: workspace:^0.7.25 + version: link:../../foundations/core/packages/api-client + '@hcengineering/chunter': + specifier: workspace:^0.7.0 + version: link:../../plugins/chunter + '@hcengineering/contact': + specifier: workspace:^0.7.0 + version: link:../../plugins/contact + '@hcengineering/core': + specifier: workspace:^0.7.26 + version: link:../../foundations/core/packages/core + '@hcengineering/document': + specifier: workspace:^0.7.0 + version: link:../../plugins/document + '@hcengineering/drive': + specifier: workspace:^0.7.0 + version: link:../../plugins/drive + '@hcengineering/platform': + specifier: workspace:^0.7.20 + version: link:../../foundations/core/packages/platform + '@hcengineering/server-core': + specifier: workspace:^0.7.19 + version: link:../../foundations/server/packages/core + '@hcengineering/server-token': + specifier: workspace:^0.7.18 + version: link:../../foundations/core/packages/token + '@hcengineering/task': + specifier: workspace:^0.7.0 + version: link:../../plugins/task + '@hcengineering/tracker': + specifier: workspace:^0.7.0 + version: link:../../plugins/tracker + cors: + specifier: ^2.8.5 + version: 2.8.5 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + morgan: + specifier: ^1.10.0 + version: 1.10.1 + devDependencies: + '@hcengineering/platform-rig': + specifier: workspace:^0.7.21 + version: link:../../foundations/utils/packages/platform-rig + '@types/cors': + specifier: ^2.8.12 + version: 2.8.19 + '@types/express': + specifier: ^4.17.13 + version: 4.17.25 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/morgan': + specifier: ~1.9.9 + version: 1.9.10 + '@types/node': + specifier: ^24.13.3 + version: 24.13.6 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + cross-env: + specifier: ~7.0.3 + version: 7.0.3 + esbuild: + specifier: ^0.25.10 + version: 0.25.12 + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-node: + specifier: ^11.1.0 + version: 11.1.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.13.6)(ts-node@10.9.2(@types/node@24.13.6)(typescript@5.9.3)) + prettier: + specifier: ^3.6.2 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@24.13.6)(ts-node@10.9.2(@types/node@24.13.6)(typescript@5.9.3)))(typescript@5.9.3) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@24.13.6)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + ../../pods/media: dependencies: '@hcengineering/account-client': diff --git a/common/scripts/docker.sh b/common/scripts/docker.sh index aa3326de40..c007215913 100755 --- a/common/scripts/docker.sh +++ b/common/scripts/docker.sh @@ -51,6 +51,7 @@ else --to @hcengineering/pod-media \ --to @hcengineering/pod-preview \ --to @hcengineering/pod-link-preview \ + --to @hcengineering/pod-mcp \ --to @hcengineering/pod-external \ --to @hcengineering/pod-backup \ --to @hcengineering/backup-api-pod \ diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 7bd59d7873..b6cce44736 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -51,6 +51,22 @@ services: ports: - 4041:4041 restart: unless-stopped + mcp: + image: 'platformcollective/mcp' + extra_hosts: + - 'huly.local:host-gateway' + container_name: mcp + environment: + - SECRET=secret + - PORT=4090 + - ACCOUNTS_URL=http://huly.local:3000 + # Set HULY_TOKEN (or HULY_EMAIL + HULY_PASSWORD) to run in self-hosted + # mode. Without it the server expects each MCP client to send its own + # Huly API token, which is not practical from Claude Desktop. + # - HULY_TOKEN=${HULY_TOKEN} + ports: + - 4090:4090 + restart: unless-stopped cockroach: image: cockroachdb/cockroach:latest-v24.3 extra_hosts: diff --git a/docs/agentlog.md b/docs/agentlog.md new file mode 100644 index 0000000000..18329e8cb9 --- /dev/null +++ b/docs/agentlog.md @@ -0,0 +1,21 @@ +# Agent log + +Appended by each agent so work can be resumed across sessions. Read only the tail. + +--- + +[2026-09-30] Added `@hcengineering/pod-mcp` — a Model Context Protocol server over Streamable HTTP, registered in `rush.json`, `common/scripts/docker.sh`, `dev/docker-compose.yaml` and `ARCHITECTURE_OVERVIEW.md`. Branch `feat/mcp-http-server`, based on `origin/develop`. + +[2026-09-30] Decision: built in THIS repo rather than a separate repository. Reasons: (1) the pod consumes `@hcengineering/*` as `workspace:^` deps, so it can never drift from the platform API; out of repo it would pin published versions, and `@hcengineering/document` is `shouldPublish: false` so it is not even in the publish pipeline; (2) the Docker image ships automatically on the next `v*` tag with zero CI changes, because `docker:build`/`docker:push` are blanket rush commands; (3) self-hosters get it by adding one compose service; (4) it inherits the platform's logging, metrics and analytics conventions. + +[2026-09-30] Rejected the community repo `ZubeidHendricks/huly-mcp` as a source to copy. Audited it: 6 commits over 4 days, last touched 2025-05-01. It is NOT an MCP server — it uses a custom JSON-RPC dialect (`huly.findPerson`, `huly.createIssue`) with no `initialize`/`tools/list`/`tools/call`. `src/api/hulyApi.ts` is 100% hardcoded fixtures and `src/index.ts` hardcodes `MOCK_MODE = true`. There is NO LICENSE file (package.json claims MIT but no grant exists), so there is nothing to vendor legally. It has no auth, CORS `*` on write endpoints, and a Dockerfile that cannot build from a clean clone. Its 8-action inventory was used only as a feature checklist. + +[2026-09-30] Auth design: two modes behind one `Authenticator` interface. `configured` (self-host) reads `HULY_TOKEN` or `HULY_EMAIL`+`HULY_PASSWORD` from the pod env so MCP clients need no Huly credential; `perRequest` (multi-tenant) has the client send its own Huly API token. Decorators: optional `MCP_ALLOWED_TOKENS` allowlist, and `MCP_READONLY` clamp applied outermost so it cannot be bypassed by a token flag. + +[2026-09-30] Security decisions worth remembering: registered `setApiTokenRevocationChecker` at boot, because 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. Never uses the system account, so all writes stay permission-checked and attributed to the caller. Added a per-client rate limiter because standalone pods get no limiting from the platform. + +[2026-09-30] No `@modelcontextprotocol/sdk` dependency was added on purpose. The server side of MCP is JSON-RPC 2.0 plus a small envelope, and the repo has zero external AI SDK deps and no zod. `src/mcp/` is transport- and platform-agnostic and unit tested. Swapping in the official SDK later is a change to `src/mcp/` only. + +[2026-09-30] FIXED: first `rush update` failed — `eslint-plugin-promise@^6.21.0` does not exist (latest is 7.3.0). Corrected to `^6.1.1` to match `pods/link-preview`. Also corrected `api-client` to `workspace:^0.7.19` and `analytics-service` to `workspace:^0.7.18` to match the real package versions; the wrong values would have failed `rush check` and `common/scripts/check-versions.js` in CI. + +[2026-09-30] NEXT: verify `rush build --to @hcengineering/pod-mcp` and `rushx test` pass, then open the PR against `develop`. diff --git a/pods/mcp/.eslintrc.js b/pods/mcp/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/pods/mcp/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/pods/mcp/Dockerfile b/pods/mcp/Dockerfile new file mode 100644 index 0000000000..b61fe55377 --- /dev/null +++ b/pods/mcp/Dockerfile @@ -0,0 +1,9 @@ +FROM platformcollective/base-slim:v20260811 + +WORKDIR /app + +COPY bundle/bundle.js ./ +COPY bundle/bundle.js.map ./ + +EXPOSE 4090 +CMD ["dumb-init", "node", "bundle.js"] diff --git a/pods/mcp/README.md b/pods/mcp/README.md new file mode 100644 index 0000000000..abc9252144 --- /dev/null +++ b/pods/mcp/README.md @@ -0,0 +1,143 @@ +# Huly MCP Server + +A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes a +Huly workspace to AI agents. It speaks MCP over Streamable HTTP and is built as a +regular pod in the platform monorepo, so it ships in the same Docker release as +everything else and is version-locked to the Huly API it talks to. + +## How it works + +``` +Claude / Cursor / OpenCode + │ POST /mcp (JSON-RPC 2.0) + ▼ + @hcengineering/pod-mcp + │ REST over HTTP + ▼ + pods/server (transactor) ──► Mongo / Postgres +``` + +The pod never opens a WebSocket. It uses `@hcengineering/api-client`, the same +stateless REST path that `pod-print` and `pod-export` use, which means one +workspace client per account rather than one socket per session. + +## Configuration + +### Self-hosted (recommended for a single team) + +Give the pod a Huly URL and a credential once, in compose. Every MCP client then +connects to the pod's own URL and needs no Huly credential of its own — this is +what makes the endpoint usable from Claude Desktop or a phone. + +```yaml +services: + mcp: + image: platformcollective/mcp + extra_hosts: + - 'huly.local:host-gateway' + environment: + - SECRET= + - ACCOUNTS_URL=https://huly.your-company.com + - HULY_TOKEN= # or HULY_EMAIL + HULY_PASSWORD + - HULY_WORKSPACE= # optional pin + ports: + - 4090:4090 + restart: unless-stopped +``` + +Create the API token in Huly under **Settings → API Tokens**. The token carries +the full rights of the account that created it, so use a dedicated service +account rather than your own. + +### Multi-tenant + +Leave the credential out and callers present their own Huly API token: + +```yaml + environment: + - SECRET= + - ACCOUNTS_URL=https://huly.your-company.com + - MCP_ALLOWED_TOKENS=, # optional pin to specific tokens +``` + +Every request must then carry `Authorization: Bearer `, and +sessions are pinned to the account that opened them. + +### All environment variables + +| Variable | Default | Meaning | +| --- | --- | --- | +| `PORT` | `4090` | HTTP listen port | +| `HOST` | `0.0.0.0` | Bind address | +| `SECRET` | — | **Required.** Shared Huly signing secret. The pod refuses to start on the default. | +| `ACCOUNTS_URL` | `http://huly.local:3000` | Account service URL; the public Huly base URL in a self-hosted install | +| `SERVICE_ID` | `mcp` | Service name used in logs and metrics | +| `HULY_TOKEN` | — | Static Huly API token. Presence selects self-hosted mode. | +| `HULY_EMAIL` / `HULY_PASSWORD` | — | Static login, used when no token is set | +| `HULY_WORKSPACE` | — | Pin the configured account to one workspace | +| `MCP_READONLY` | `false` | Refuse every write tool regardless of credentials | +| `MCP_ALLOWED_TOKENS` | — | Comma-separated allowlist for multi-tenant mode | +| `MCP_SESSION_TTL_MS` | `1800000` | Idle session lifetime | +| `MCP_CLIENT_CACHE_TTL_MS` | `600000` | Idle workspace-client cache lifetime | +| `MCP_LOGIN_CACHE_TTL_MS` | `60000` | How long a login result is reused | +| `MCP_RATE_LIMIT` | `300` | Requests per window, per client | +| `MCP_RATE_WINDOW_MS` | `60000` | Rate limit window | +| `MCP_MAX_BODY_BYTES` | `1048576` | Max JSON-RPC request body | +| `MCP_STATS` | `true` | Serve `/api/v1/statistics` | + +## Connecting a client + +```json +{ + "mcpServers": { + "huly": { + "type": "http", + "url": "https://mcp.your-company.com/mcp" + } + } +} +``` + +In self-hosted mode add the pod's URL only. In multi-tenant mode also add the +bearer token your client supports. + +## Tools + +Read: + +- `huly_search` — full-text search across issues, projects, documents, tasks, milestones, people +- `huly_list_projects`, `huly_get_project` +- `huly_list_issues`, `huly_get_issue`, `huly_list_issue_statuses` +- `huly_list_tasks`, `huly_find_people` +- `huly_list_spaces`, `huly_list_drives`, `huly_list_documents`, `huly_get_document` +- `huly_list_milestones` + +Write (refused when read-only): + +- `huly_create_issue`, `huly_update_issue`, `huly_add_issue_comment` +- `huly_create_milestone`, `huly_create_person` + +## Security notes + +- **Write access is the caller's, not the server's.** Every write goes through + `TxOperations`, which is permission-checked by the transactor. The pod never + uses the system account, so a tool can never exceed the caller's rights. +- **Sessions are pinned.** A session id is bound to the account and workspace + that created it; presenting it with a different token returns 403. +- **Revocation is enforced.** `verifyToken` plus a revocation checker wired to + the account service means a revoked API token stops working. +- **Guest tokens are rejected**, as are read-only tokens for write tools. +- **Rate limited** per client, because standalone pods get no limiting from the + platform. + +## Development + +```bash +rush install +rush build --to @hcengineering/pod-mcp +rushx test --to @hcengineering/pod-mcp +rushx run-local # needs SECRET, ACCOUNTS_URL and credentials +``` + +The MCP protocol layer has no dependency on Huly: `src/mcp/` is transport- and +platform-agnostic and unit tested on its own. diff --git a/pods/mcp/config/rig.json b/pods/mcp/config/rig.json new file mode 100644 index 0000000000..b94bbb0650 --- /dev/null +++ b/pods/mcp/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "node" +} diff --git a/pods/mcp/jest.config.js b/pods/mcp/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/pods/mcp/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/pods/mcp/package.json b/pods/mcp/package.json new file mode 100644 index 0000000000..5143b6628c --- /dev/null +++ b/pods/mcp/package.json @@ -0,0 +1,78 @@ +{ + "name": "@hcengineering/pod-mcp", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "template": "@hcengineering/node-package", + "license": "EPL-2.0", + "scripts": { + "start": "ts-node src/index.ts", + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent", + "_phase:bundle": "rushx bundle", + "_phase:docker-build": "rushx docker:build", + "_phase:docker-staging": "rushx docker:staging", + "bundle": "node ../../common/scripts/esbuild.js --keep-names=true --sourcemap=external", + "docker:build": "../../common/scripts/docker_build.sh platformcollective/mcp", + "docker:staging": "../../common/scripts/docker_tag.sh platformcollective/mcp staging", + "docker:abuild": "docker build -t platformcollective/mcp . --platform=linux/arm64 && ../../common/scripts/docker_tag_push.sh platformcollective/mcp", + "docker:push": "../../common/scripts/docker_tag.sh platformcollective/mcp", + "run-local": "ts-node src/index.ts", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "workspace:^0.7.21", + "@types/jest": "^29.5.5", + "@types/node": "^24.13.3", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "cross-env": "~7.0.3", + "esbuild": "^0.25.10", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.6.2", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "typescript": "^5.9.3", + "@types/cors": "^2.8.12", + "@types/express": "^4.17.13", + "@types/morgan": "~1.9.9" + }, + "dependencies": { + "@hcengineering/account-client": "workspace:^0.7.25", + "@hcengineering/analytics": "workspace:^0.7.19", + "@hcengineering/analytics-service": "workspace:^0.7.19", + "@hcengineering/api-client": "workspace:^0.7.25", + "@hcengineering/chunter": "workspace:^0.7.0", + "@hcengineering/contact": "workspace:^0.7.0", + "@hcengineering/core": "workspace:^0.7.26", + "@hcengineering/document": "workspace:^0.7.0", + "@hcengineering/drive": "workspace:^0.7.0", + "@hcengineering/platform": "workspace:^0.7.20", + "@hcengineering/server-core": "workspace:^0.7.19", + "@hcengineering/server-token": "workspace:^0.7.18", + "@hcengineering/task": "workspace:^0.7.0", + "@hcengineering/tracker": "workspace:^0.7.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "morgan": "^1.10.0" + } +} diff --git a/pods/mcp/src/__tests__/test-doubles.ts b/pods/mcp/src/__tests__/test-doubles.ts new file mode 100644 index 0000000000..6a10a3804d --- /dev/null +++ b/pods/mcp/src/__tests__/test-doubles.ts @@ -0,0 +1,86 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type AccountUuid, type MeasureContext, type TxOperations, type WorkspaceUuid } from '@hcengineering/core' + +import { type SessionIdentity } from '../auth/authenticator' +import { type WorkspaceSession } from '../platform/workspace-client-provider' +import { toolContext, type ToolContext } from '../mcp/tool' + +/** + * Test doubles for the MCP layer. + * + * The assertions here deliberately go through a named variable rather than an + * inline object literal, because the repository's ESLint config forbids + * asserting an object literal (`consistent-type-assertions`). Keeping the casts + * in one file also means the protocol tests stay free of Huly specifics. + */ + +const ACCOUNT = 'account-1' as AccountUuid +const WORKSPACE = 'workspace-1' as WorkspaceUuid + +export const fakeIdentity = (overrides: Partial = {}): SessionIdentity => { + const base: SessionIdentity = { + account: ACCOUNT, + workspace: WORKSPACE, + token: { account: ACCOUNT, workspace: WORKSPACE }, + workspaceToken: 'raw-token', + transactorUrl: 'http://transactor.test', + readOnly: false + } + return { ...base, ...overrides } +} + +export const fakeWorkspaceSession = (identity: SessionIdentity = fakeIdentity()): WorkspaceSession => { + // `Object.create` rather than a literal: the repo's ESLint config forbids + // asserting an object literal, and an empty client is a legal stub here. + const base = { + identity, + client: Object.create(null) as TxOperations, + markup: { read: async (_ref: string) => '' } + } + return base as unknown as WorkspaceSession +} + +export const fakeToolContext = ( + identity: SessionIdentity = fakeIdentity(), + ctx: MeasureContext = fakeMeasureContext() +): ToolContext => toolContext(fakeWorkspaceSession(identity), ctx) + +/** + * A MeasureContext that satisfies the handful of methods the MCP layer calls. + * + * `with` is the important one: tools and the dispatcher wrap their work in it, + * so a stub that does not invoke the callback would silently skip every test. + */ +export const fakeMeasureContext = (): MeasureContext => { + const ctx: Record = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + setLevel: () => {}, + newChild: () => ctx, + with: async (_name: string, _params: unknown, fn: (child: MeasureContext) => Promise) => + await fn(ctx as unknown as MeasureContext) + } + return ctx as unknown as MeasureContext +} + +/** Builds a ProcessEnv without asserting an object literal. */ +export const fakeEnv = (values: Record = {}): NodeJS.ProcessEnv => { + const env: NodeJS.ProcessEnv = { ...values } + return env +} diff --git a/pods/mcp/src/auth/__tests__/auth.test.ts b/pods/mcp/src/auth/__tests__/auth.test.ts new file mode 100644 index 0000000000..146bd2f7cd --- /dev/null +++ b/pods/mcp/src/auth/__tests__/auth.test.ts @@ -0,0 +1,84 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { resolveAuthMode } from '../../config' +import { fakeEnv } from '../../__tests__/test-doubles' +import { AuthenticationError, toTransactorHttpUrl } from '../authenticator' +import { extractBearerToken } from '../token-extractor' + +describe('toTransactorHttpUrl', () => { + it('maps both websocket schemes to their http twins', () => { + expect(toTransactorHttpUrl('ws://huly.local:3030')).toBe('http://huly.local:3030') + expect(toTransactorHttpUrl('wss://huly.example.com')).toBe('https://huly.example.com') + }) + + it('leaves an already-http url alone', () => { + expect(toTransactorHttpUrl('http://huly.local:3030')).toBe('http://huly.local:3030') + }) +}) + +describe('extractBearerToken', () => { + const header = (value: string | undefined): { authorization?: string } => ({ authorization: value }) + + it('reads a bearer credential', () => { + expect(extractBearerToken(header('Bearer abc'))).toBe('abc') + }) + + it('is case-insensitive about the scheme, as RFC 7235 requires', () => { + expect(extractBearerToken(header('bearer abc'))).toBe('abc') + expect(extractBearerToken(header('BEARER abc'))).toBe('abc') + }) + + it('tolerates surrounding whitespace', () => { + expect(extractBearerToken(header(' Bearer abc '))).toBe('abc') + }) + + it('rejects a missing, empty or non-bearer credential', () => { + expect(extractBearerToken(header(undefined))).toBeUndefined() + expect(extractBearerToken(header('Bearer'))).toBeUndefined() + expect(extractBearerToken(header('Bearer '))).toBeUndefined() + expect(extractBearerToken(header('Basic abc'))).toBeUndefined() + }) +}) + +describe('resolveAuthMode', () => { + it('defaults to perRequest when no credentials are configured', () => { + expect(resolveAuthMode(fakeEnv())).toBe('perRequest') + }) + + it('selects configured mode when an API token is present', () => { + expect(resolveAuthMode(fakeEnv({ HULY_TOKEN: 'abc' }))).toBe('configured') + }) + + it('selects configured mode when both email and password are present', () => { + expect(resolveAuthMode(fakeEnv({ HULY_EMAIL: 'a@b.c', HULY_PASSWORD: 'pw' }))).toBe('configured') + }) + + it('ignores an email with no password', () => { + expect(resolveAuthMode(fakeEnv({ HULY_EMAIL: 'a@b.c' }))).toBe('perRequest') + }) + + it('prefers configured mode when both are set', () => { + expect(resolveAuthMode(fakeEnv({ HULY_TOKEN: 'abc', MCP_ALLOWED_TOKENS: 'x' }))).toBe('configured') + }) +}) + +describe('AuthenticationError', () => { + it('carries a machine-readable reason', () => { + const err = new AuthenticationError('guest', 'nope') + expect(err.reason).toBe('guest') + expect(err).toBeInstanceOf(Error) + }) +}) diff --git a/pods/mcp/src/auth/authenticator-factory.ts b/pods/mcp/src/auth/authenticator-factory.ts new file mode 100644 index 0000000000..6e68c20aec --- /dev/null +++ b/pods/mcp/src/auth/authenticator-factory.ts @@ -0,0 +1,94 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type MeasureContext } from '@hcengineering/core' + +import { type Config } from '../config' +import { AuthenticationError, type Authenticator, type SessionIdentity } from './authenticator' +import { ConfiguredAuthenticator } from './configured-authenticator' +import { HulyTokenAuthenticator } from './huly-token-authenticator' + +/** + * Optional allowlist applied on top of per-request authentication. + * + * Huly API tokens carry the full rights of their account and cannot be scoped + * narrower, so a shared multi-tenant endpoint needs its own gate: an operator + * can pin the endpoint to specific tokens without changing Huly itself. + */ +class AllowlistedAuthenticator implements Authenticator { + private readonly inner: Authenticator + private readonly allowed: Set + + constructor (inner: Authenticator, allowed: string[]) { + this.inner = inner + this.allowed = new Set(allowed) + } + + async authenticate (rawToken: string): Promise { + if (!this.allowed.has(rawToken)) { + throw new AuthenticationError('forbidden', 'This token is not permitted to use this MCP endpoint') + } + return await this.inner.authenticate(rawToken) + } +} + +/** Forces every identity to be read-only, whatever the underlying token says. */ +class ReadOnlyAuthenticator implements Authenticator { + private readonly inner: Authenticator + + constructor (inner: Authenticator) { + this.inner = inner + } + + async authenticate (rawToken: string): Promise { + const identity = await this.inner.authenticate(rawToken) + return { ...identity, readOnly: true } + } +} + +/** + * Chooses the authenticator for a deployment. + * + * Decorators are applied outermost-last so the read-only clamp always wins: it + * is the operator's bluntest control and must not be bypassable by a token flag. + */ +export function createAuthenticator (ctx: MeasureContext, config: Config): Authenticator { + let authenticator: Authenticator + + if (config.AuthMode === 'configured') { + authenticator = new ConfiguredAuthenticator(ctx, config) + } else { + authenticator = new HulyTokenAuthenticator(ctx, { + accountsUrl: config.AccountsUrl, + cacheTtlMs: config.LoginCacheTtlMs + }) + } + + if (config.AllowedTokens.length > 0) { + authenticator = new AllowlistedAuthenticator(authenticator, config.AllowedTokens) + } + + if (config.ReadOnly) { + authenticator = new ReadOnlyAuthenticator(authenticator) + } + + ctx.info('mcp authenticator ready', { + mode: config.AuthMode, + allowlist: config.AllowedTokens.length > 0, + readOnly: config.ReadOnly + }) + + return authenticator +} diff --git a/pods/mcp/src/auth/authenticator.ts b/pods/mcp/src/auth/authenticator.ts new file mode 100644 index 0000000000..38ae034f8d --- /dev/null +++ b/pods/mcp/src/auth/authenticator.ts @@ -0,0 +1,68 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type AccountUuid, type WorkspaceUuid } from '@hcengineering/core' +import { type Token } from '@hcengineering/server-token' + +/** + * Everything the request pipeline needs to act on behalf of one caller. + * + * Resolved once per request, then frozen onto the MCP session: a session can + * never change identity halfway through, so a stolen `Mcp-Session-Id` is useless + * on its own and a token swap cannot escalate a live session. + */ +export interface SessionIdentity { + /** Global person id (Huly `AccountUuid`). */ + account: AccountUuid + /** Workspace the token is scoped to. */ + workspace: WorkspaceUuid + /** The verified, unexpired, non-revoked token. */ + token: Token + /** Re-signed token bound to `workspace`, used to talk to the transactor. */ + workspaceToken: string + /** Transactor endpoint in http(s) form, derived from the ws endpoint. */ + transactorUrl: string + /** True when the token carries `extra.readonly === 'true'`. */ + readOnly: boolean +} + +export interface Authenticator { + /** + * Verifies a raw bearer token and resolves it to a workspace-scoped identity. + * Rejects with `AuthenticationError` for any credential that must not be used + * against this server. + */ + authenticate: (rawToken: string) => Promise +} + +export type AuthenticationFailure = 'missing' | 'invalid' | 'guest' | 'forbidden' + +export class AuthenticationError extends Error { + readonly reason: AuthenticationFailure + + constructor (reason: AuthenticationFailure, message: string) { + super(message) + this.name = 'AuthenticationError' + this.reason = reason + } +} + +/** + * Turns a WebSocket transactor endpoint into its HTTP twin, which is what the + * REST client and the blob endpoint expect. + */ +export function toTransactorHttpUrl (endpoint: string): string { + return endpoint.replace(/^ws:\/\//, 'http://').replace(/^wss:\/\//, 'https://') +} diff --git a/pods/mcp/src/auth/configured-authenticator.ts b/pods/mcp/src/auth/configured-authenticator.ts new file mode 100644 index 0000000000..14c72bb802 --- /dev/null +++ b/pods/mcp/src/auth/configured-authenticator.ts @@ -0,0 +1,145 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { + getClient as getAccountClient, + isWorkspaceLoginInfo, + type AccountClient, + type LoginInfoByToken +} from '@hcengineering/account-client' +import { type MeasureContext } from '@hcengineering/core' +import { decodeToken } from '@hcengineering/server-token' + +import { type Config } from '../config' +import { AuthenticationError, type Authenticator, type SessionIdentity, toTransactorHttpUrl } from './authenticator' + +/** + * Authenticates every caller as one account configured on the pod itself. + * + * This is the shape a self-hosted install actually needs. The operator supplies + * Huly's URL and a credential once, in compose; every MCP client (Claude + * Desktop, an IDE, a phone) then connects to this pod with nothing but the pod's + * own URL. Handing a Huly token to each agent instead would mean distributing + * workspace-wide write access to every tool the user has ever installed. + * + * The identity is resolved lazily and cached, so the first tool call pays the + * login round trip and later calls reuse it until the TTL expires. + */ +export class ConfiguredAuthenticator implements Authenticator { + private readonly ctx: MeasureContext + private readonly config: Config + private cached: { identity: SessionIdentity, expiresOn: number } | undefined + private inFlight: Promise | undefined + + constructor (ctx: MeasureContext, config: Config) { + this.ctx = ctx + this.config = config + } + + async authenticate (): Promise { + const now = Date.now() + if (this.cached !== undefined && this.cached.expiresOn > now) { + return this.cached.identity + } + + // Collapse a cold-start stampede: many MCP clients call initialize at once + // on reconnect, and each would otherwise trigger its own login. + if (this.inFlight !== undefined) return await this.inFlight + + const creation = this.resolve() + this.inFlight = creation + try { + return await creation + } finally { + this.inFlight = undefined + } + } + + private async resolve (): Promise { + const identity = this.toIdentity(await this.login()) + // Kept short on purpose: a configured password or token can be rotated out + // of band, and a long cache would keep using the old one after that. + this.cached = { identity, expiresOn: Date.now() + this.config.LoginCacheTtlMs } + this.ctx.info('mcp configured identity resolved', { + workspace: identity.workspace, + readOnly: identity.readOnly + }) + return identity + } + + private async login (): Promise { + try { + if (this.config.HulyToken !== '') { + const client = getAccountClient(this.config.AccountsUrl, this.config.HulyToken) + return await client.getLoginInfoByToken() + } + const client: AccountClient = getAccountClient(this.config.AccountsUrl) + return await client.login(this.config.HulyEmail, this.config.HulyPassword) + } catch (err) { + this.ctx.error('mcp configured login failed', { error: (err as Error)?.message }) + throw new AuthenticationError( + 'invalid', + 'The configured Huly credentials were rejected. Check HULY_TOKEN or HULY_EMAIL/HULY_PASSWORD.' + ) + } + } + + private toIdentity (loginInfo: LoginInfoByToken): SessionIdentity { + if (!isWorkspaceLoginInfo(loginInfo)) { + throw new AuthenticationError( + 'invalid', + this.config.HulyWorkspace === '' + ? 'The configured account is not bound to a workspace. Set HULY_WORKSPACE to pick one.' + : `The configured account could not be resolved to workspace "${this.config.HulyWorkspace}".` + ) + } + + if (this.config.HulyWorkspace !== '' && !matchesWorkspace(loginInfo, this.config.HulyWorkspace)) { + throw new AuthenticationError( + 'forbidden', + `The configured account is bound to a different workspace than HULY_WORKSPACE="${this.config.HulyWorkspace}".` + ) + } + + // The account service just minted this token and scoped it to the + // workspace, so decoding it locally is enough to learn who we act as. + // `decodeToken` verifies the signature, which also proves the account + // service and this pod share SECRET. + let token + try { + token = decodeToken(loginInfo.token) + } catch { + throw new AuthenticationError('invalid', 'The account service returned a token we cannot verify') + } + + return { + account: token.account, + workspace: loginInfo.workspace, + token, + workspaceToken: loginInfo.token, + transactorUrl: toTransactorHttpUrl(loginInfo.endpoint), + readOnly: this.config.ReadOnly || token.extra?.readonly === 'true' + } + } +} + +/** Accepts a workspace id, its url slug, or a case-insensitive id. */ +function matchesWorkspace (loginInfo: { workspace: string, workspaceUrl: string }, wanted: string): boolean { + return ( + loginInfo.workspace === wanted || + loginInfo.workspaceUrl === wanted || + loginInfo.workspace === wanted.toLowerCase() + ) +} diff --git a/pods/mcp/src/auth/http-types.ts b/pods/mcp/src/auth/http-types.ts new file mode 100644 index 0000000000..a6517e4f97 --- /dev/null +++ b/pods/mcp/src/auth/http-types.ts @@ -0,0 +1,22 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type Request } from 'express' +import { type Token } from '@hcengineering/server-token' + +/** Request shape used by the auth pipeline: a plain Express request. */ +export type RequestWithAuth = Request & { + token?: Token +} diff --git a/pods/mcp/src/auth/huly-token-authenticator.ts b/pods/mcp/src/auth/huly-token-authenticator.ts new file mode 100644 index 0000000000..cc7ee56fed --- /dev/null +++ b/pods/mcp/src/auth/huly-token-authenticator.ts @@ -0,0 +1,169 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { getClient as getAccountClient, isWorkspaceLoginInfo, type AccountClient, type LoginInfoByToken } from '@hcengineering/account-client' +import { type MeasureContext } from '@hcengineering/core' +import { setApiTokenRevocationChecker, verifyToken } from '@hcengineering/server-token' + +import { type Config } from '../config' +import { AuthenticationError, type Authenticator, type SessionIdentity, toTransactorHttpUrl } from './authenticator' + +export interface HulyTokenAuthenticatorOptions { + accountsUrl: string + /** TTL for the cached account-service round trip, in milliseconds. */ + cacheTtlMs?: number + now?: () => number +} + +interface CacheEntry { + identity: SessionIdentity + expiresOn: number +} + +const DEFAULT_CACHE_TTL_MS = 60_000 + +/** + * Authenticates MCP callers with a regular Huly API token. + * + * Two independent checks are performed on purpose: + * + * 1. `verifyToken` — signature, expiry and revocation. Revocation is only + * enforced when a process registers a checker, which is why + * `registerRevocationChecker` runs in the constructor. + * 2. `getLoginInfoByToken` — the account service is the authority on whether a + * token still maps to a live account and workspace, and it hands back a token + * already scoped to that workspace. + * + * The second check costs one round trip, so successful results are cached for a + * short TTL. The TTL is deliberately short: revocation is enforced fail-closed + * in the transactor, and a minute of staleness here is the price for not hitting + * the account service on every single tool call. + */ +export class HulyTokenAuthenticator implements Authenticator { + private readonly ctx: MeasureContext + private readonly accountsUrl: string + private readonly cacheTtlMs: number + private readonly now: () => number + private readonly cache = new Map() + + constructor (ctx: MeasureContext, options: HulyTokenAuthenticatorOptions) { + this.ctx = ctx + this.accountsUrl = options.accountsUrl + this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS + this.now = options.now ?? Date.now + + this.registerRevocationChecker() + } + + /** + * Makes `verifyToken` reject revoked API tokens. Without this, a revoked token + * is silently accepted by this process — see `setApiTokenRevocationChecker`. + */ + private registerRevocationChecker (): void { + setApiTokenRevocationChecker(async (_apiTokenId: string, _decoded: unknown, raw: string) => { + try { + await this.accountClient(raw).getLoginInfoByToken() + return false + } catch (err) { + // Only an explicit Unauthorized means "revoked". Anything else (network + // hiccup, account service restart) rethrows so the checker's own + // fail-closed path decides, instead of us guessing. + const status = (err as { status?: { code?: number } })?.status?.code + if (status === 401) return true + throw err + } + }) + } + + private accountClient (token: string): AccountClient { + return getAccountClient(this.accountsUrl, token) + } + + async authenticate (rawToken: string): Promise { + if (rawToken === '') { + throw new AuthenticationError('missing', 'Missing bearer token') + } + + const cached = this.cache.get(rawToken) + if (cached !== undefined && cached.expiresOn > this.now()) { + return cached.identity + } + + const token = await this.verify(rawToken) + this.assertUsable(token.extra) + + const identity = await this.resolveWorkspace(rawToken, token) + + this.cache.set(rawToken, { identity, expiresOn: this.now() + this.cacheTtlMs }) + this.sweepCache() + + return identity + } + + private async verify (rawToken: string): Promise { + try { + return await verifyToken(rawToken) + } catch (err) { + this.ctx.warn('mcp token rejected', { error: (err as Error)?.message }) + // Expired, revoked, malformed and unverifiable are indistinguishable from + // the outside by design — the client only learns that the token is bad. + throw new AuthenticationError('invalid', 'Invalid or expired token') + } + } + + private assertUsable (extra: Record | undefined): void { + if (extra?.guest === 'true') { + throw new AuthenticationError('guest', 'Guest tokens cannot be used against the MCP server') + } + if (extra?.readonly === 'true' && extra?.admin === 'true') { + throw new AuthenticationError('forbidden', 'Conflicting token flags') + } + } + + private async resolveWorkspace (rawToken: string, token: SessionIdentity['token']): Promise { + let loginInfo: LoginInfoByToken + try { + loginInfo = await this.accountClient(rawToken).getLoginInfoByToken() + } catch (err) { + this.ctx.warn('mcp account lookup failed', { error: (err as Error)?.message }) + throw new AuthenticationError('invalid', 'Invalid or revoked token') + } + + if (!isWorkspaceLoginInfo(loginInfo)) { + throw new AuthenticationError('invalid', 'Token is not bound to a workspace') + } + + if (loginInfo.workspace !== token.workspace) { + throw new AuthenticationError('forbidden', 'Token workspace mismatch') + } + + return { + account: token.account, + workspace: token.workspace, + token, + workspaceToken: loginInfo.token, + transactorUrl: toTransactorHttpUrl(loginInfo.endpoint), + readOnly: token.extra?.readonly === 'true' + } + } + + private sweepCache (): void { + if (this.cache.size <= 1024) return + const now = this.now() + for (const [key, entry] of this.cache) { + if (entry.expiresOn <= now) this.cache.delete(key) + } + } +} diff --git a/pods/mcp/src/auth/token-extractor.ts b/pods/mcp/src/auth/token-extractor.ts new file mode 100644 index 0000000000..70ad78480a --- /dev/null +++ b/pods/mcp/src/auth/token-extractor.ts @@ -0,0 +1,39 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +const BEARER = 'bearer ' + +/** + * Reads the raw bearer credential out of a set of request headers. + * + * Takes headers rather than a Request so it stays a pure function of its input + * and is testable without constructing an Express object. + * + * `decodeToken` is deliberately not used here: it swallows the reason a token + * failed, and the HTTP layer needs to tell "no credential" (401 plus + * `WWW-Authenticate`) apart from "bad credential" (401) so clients can react. + */ +export function extractBearerToken (headers: { authorization?: string | string[] }): string | undefined { + const header = headers.authorization + if (typeof header !== 'string') return undefined + + const value = header.trim() + if (value.toLowerCase().startsWith(BEARER)) { + const token = value.slice(BEARER.length).trim() + return token.length > 0 ? token : undefined + } + + return undefined +} diff --git a/pods/mcp/src/config.ts b/pods/mcp/src/config.ts new file mode 100644 index 0000000000..c2cfa515a0 --- /dev/null +++ b/pods/mcp/src/config.ts @@ -0,0 +1,155 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { config as dotenv } from 'dotenv' + +dotenv() + +/** + * How the server decides who the caller is. + * + * - `configured`: a self-hosted deployment. Credentials come from the pod's own + * environment (HULY_TOKEN, or HULY_EMAIL + HULY_PASSWORD) and every session + * runs as that one account. The MCP client needs no Huly credential at all, + * which is what makes this usable from Claude Desktop or a phone. + * - `perRequest`: the MCP client presents its own Huly API token on every + * request. Used when several teams share one MCP endpoint and each must act as + * itself. + */ +export type AuthMode = 'configured' | 'perRequest' + +export interface Config { + Port: number + Host: string + Secret: string + ServiceID: string + /** Account service URL; the public Huly base URL in a self-hosted install. */ + AccountsUrl: string + AuthMode: AuthMode + /** Static API token for `configured` mode. */ + HulyToken: string + /** Static login for `configured` mode when no API token is supplied. */ + HulyEmail: string + HulyPassword: string + /** Restrict `configured` mode to one workspace, by id or url slug. */ + HulyWorkspace: string + /** Drop write tools and refuse all mutations. */ + ReadOnly: boolean + /** Identifiers callers may present when `AuthMode` is `perRequest`. */ + AllowedTokens: string[] + SessionIdleTtlMs: number + ClientCacheTtlMs: number + LoginCacheTtlMs: number + RequestRateLimit: number + RequestRateWindowMs: number + MaxBodyBytes: number + EnableStats: boolean +} + +const int = (value: string | undefined, fallback: number): number => { + if (value === undefined || value === '') return fallback + const parsed = Number.parseInt(value, 10) + if (Number.isNaN(parsed)) { + throw Error(`Expected an integer but got "${value}"`) + } + return parsed +} + +const bool = (value: string | undefined, fallback: boolean): boolean => { + if (value === undefined || value === '') return fallback + return value === 'true' || value === '1' +} + +const list = (value: string | undefined): string[] => + (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + +const DEFAULT_ACCOUNTS_URL = 'http://huly.local:3000' + +/** + * Resolves the auth mode from the environment. + * + * Credentials win over `perRequest` when both are present: a self-hoster who + * pasted a token into compose clearly wants the simple single-tenant setup, and + * silently ignoring it in favour of per-request tokens would produce an endpoint + * that rejects every client. + */ +export function resolveAuthMode (env: NodeJS.ProcessEnv): AuthMode { + const hasStaticCredentials = + (env.HULY_TOKEN ?? '') !== '' || ((env.HULY_EMAIL ?? '') !== '' && (env.HULY_PASSWORD ?? '') !== '') + return hasStaticCredentials ? 'configured' : 'perRequest' +} + +function buildConfig (env: NodeJS.ProcessEnv): Config { + const authMode = resolveAuthMode(env) + + if (authMode === 'configured' && (env.HULY_TOKEN ?? '') === '' && (env.HULY_PASSWORD ?? '') === '') { + throw Error('Auth mode is "configured" but neither HULY_TOKEN nor HULY_PASSWORD is set') + } + + return { + Port: int(env.PORT, 4090), + Host: env.HOST ?? '0.0.0.0', + Secret: env.SECRET ?? '', + ServiceID: env.SERVICE_ID ?? 'mcp', + AccountsUrl: env.ACCOUNTS_URL ?? DEFAULT_ACCOUNTS_URL, + AuthMode: authMode, + HulyToken: env.HULY_TOKEN ?? '', + HulyEmail: env.HULY_EMAIL ?? '', + HulyPassword: env.HULY_PASSWORD ?? '', + HulyWorkspace: env.HULY_WORKSPACE ?? '', + ReadOnly: bool(env.MCP_READONLY, false), + AllowedTokens: list(env.MCP_ALLOWED_TOKENS), + SessionIdleTtlMs: int(env.MCP_SESSION_TTL_MS, 30 * 60_000), + ClientCacheTtlMs: int(env.MCP_CLIENT_CACHE_TTL_MS, 10 * 60_000), + LoginCacheTtlMs: int(env.MCP_LOGIN_CACHE_TTL_MS, 60_000), + RequestRateLimit: int(env.MCP_RATE_LIMIT, 300), + RequestRateWindowMs: int(env.MCP_RATE_WINDOW_MS, 60_000), + MaxBodyBytes: int(env.MCP_MAX_BODY_BYTES, 1024 * 1024), + EnableStats: bool(env.MCP_STATS, true) + } +} + +/** + * Fails fast on configuration that would otherwise only break at first use. + * + * `SECRET` is the single most dangerous omission: without it every Huly token + * verifies against the literal string "secret" (see `server-token`), so a + * misconfigured deployment would accept forged tokens. Refusing to start is the + * only safe response. + */ +function validate (config: Config): Config { + if (config.Secret === '' || config.Secret === 'secret') { + throw Error('SECRET must be set to a real secret; refusing to start with the default') + } + if (config.Port < 1 || config.Port > 65535) { + throw Error(`PORT is out of range: ${config.Port}`) + } + return config +} + +/** + * Reads and validates configuration. + * + * Deliberately a function rather than a module-level constant: importing this + * file must not require environment variables, or every unit test that touches + * a helper here would fail at import time. `index.ts` calls it once at boot, + * which is where a misconfiguration should stop the process. + */ +export function loadConfig (env: NodeJS.ProcessEnv = process.env): Config { + return validate(buildConfig(env)) +} diff --git a/pods/mcp/src/error.ts b/pods/mcp/src/error.ts new file mode 100644 index 0000000000..771722a170 --- /dev/null +++ b/pods/mcp/src/error.ts @@ -0,0 +1,24 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +export class HttpError extends Error { + readonly status: number + + constructor (status: number, message: string) { + super(message) + this.name = 'HttpError' + this.status = status + } +} diff --git a/pods/mcp/src/index.ts b/pods/mcp/src/index.ts new file mode 100644 index 0000000000..a7af44f447 --- /dev/null +++ b/pods/mcp/src/index.ts @@ -0,0 +1,136 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Analytics } from '@hcengineering/analytics' +import { configureAnalytics, createOpenTelemetryMetricsContext, SplitLogger } from '@hcengineering/analytics-service' +import { newMetrics } from '@hcengineering/core' +import { setMetadata } from '@hcengineering/platform' +import { initStatisticsContext } from '@hcengineering/server-core' +import serverToken from '@hcengineering/server-token' +import { join } from 'node:path' + +import { createAuthenticator } from './auth/authenticator-factory' +import { loadConfig } from './config' +import { RateLimiter } from './middleware/rate-limiter' +import { CachingWorkspaceClientProvider } from './platform/workspace-client-provider' +import { createServer, listen } from './server' +import { buildRegistry } from './tools/register' + +/** How often idle sessions and rate-limit buckets are reclaimed. */ +const SWEEP_INTERVAL_MS = 60_000 + +async function main (): Promise { + // Boot-time config: a missing SECRET or an unusable credential combination + // must stop the process here, not on the first request. + const config = loadConfig() + const application = config.ServiceID + + // Both of these must be set before any token is touched: without the secret, + // server-token falls back to the literal string "secret" and would accept + // forged tokens. loadConfig already refuses to boot on a default secret. + setMetadata(serverToken.metadata.Secret, config.Secret) + setMetadata(serverToken.metadata.Service, application) + + configureAnalytics(application, process.env.VERSION ?? '0.7.0') + Analytics.setTag('application', application) + + const ctx = initStatisticsContext(application, { + factory: () => + createOpenTelemetryMetricsContext( + application, + {}, + {}, + newMetrics(), + new SplitLogger(application, { + root: join(process.cwd(), 'logs'), + enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true' + }) + ) + }) + + const registry = buildRegistry() + const authenticator = createAuthenticator(ctx, config) + const clients = new CachingWorkspaceClientProvider({ ctx, idleTtlMs: config.ClientCacheTtlMs }) + const limiter = new RateLimiter(ctx, config.RequestRateLimit, config.RequestRateWindowMs) + + const { app, sessions, transport } = createServer({ + ctx, + config, + registry, + clients, + authenticator, + limiter + }) + + // Unref'd so the sweeper never keeps the process alive by itself. + const sweeper = setInterval(() => { + sessions.sweep() + limiter.sweep() + }, SWEEP_INTERVAL_MS) + sweeper.unref() + + const server = listen(app, config.Port, config.Host) + + ctx.info('mcp server ready', { + port: config.Port, + authMode: config.AuthMode, + readOnly: config.ReadOnly, + tools: registry.size, + accountsUrl: config.AccountsUrl + }) + + let shuttingDown = false + const shutdown = async (signal: string): Promise => { + if (shuttingDown) return + shuttingDown = true + + ctx.info('mcp server shutting down', { signal }) + clearInterval(sweeper) + + transport.closeAll() + sessions.closeAll() + await new Promise((resolve) => { + server.close(() => { + resolve() + }) + }) + // Close the server sockets outright; an open SSE stream would otherwise + // keep `server.close` pending until the client disconnects. + server.closeAllConnections?.() + await clients.close() + + ctx.info('mcp shutdown complete') + process.exit(0) + } + + process.on('SIGINT', () => { + void shutdown('SIGINT') + }) + process.on('SIGTERM', () => { + void shutdown('SIGTERM') + }) + process.on('uncaughtException', (error: Error) => { + ctx.error('mcp uncaught exception', { error: error?.message, stack: error?.stack }) + }) + process.on('unhandledRejection', (reason: unknown) => { + ctx.error('mcp unhandled rejection', { error: String(reason) }) + }) +} + +void main().catch((err) => { + // The logger may not exist yet if the failure happened during bootstrap. + console.error('Failed to start the Huly MCP server', err) + process.exit(1) +}) diff --git a/pods/mcp/src/mcp/__tests__/dispatcher.test.ts b/pods/mcp/src/mcp/__tests__/dispatcher.test.ts new file mode 100644 index 0000000000..679cccfbb3 --- /dev/null +++ b/pods/mcp/src/mcp/__tests__/dispatcher.test.ts @@ -0,0 +1,185 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { fakeIdentity, fakeMeasureContext, fakeWorkspaceSession } from '../../__tests__/test-doubles' +import { McpDispatcher } from '../dispatcher' +import { McpSession } from '../session' +import { ToolRegistry } from '../tool' + +const ctx = fakeMeasureContext() + +function makeDispatcher (registry: ToolRegistry): McpDispatcher { + return new McpDispatcher({ + ctx, + registry, + serverName: 'huly-mcp', + serverVersion: '0.7.0', + instructions: 'hello', + resolveSession: async (session) => fakeWorkspaceSession(session.identity) + }) +} + +const makeSession = (): McpSession => new McpSession('sess-1', fakeIdentity(), 0) + +const request = (method: string, params?: unknown, id: number = 1): Record => { + const base: Record = { jsonrpc: '2.0', id, method } + return params === undefined ? base : { ...base, params } +} + +const registryWithEcho = (): ToolRegistry => + new ToolRegistry().register({ + name: 'echo', + title: 'Echo', + description: 'echoes', + readOnly: true, + inputSchema: { type: 'object', properties: { value: { type: 'string' } } }, + handler: async (_c, args) => ({ content: [{ type: 'text', text: String(args.value) }] }) + }) + +/** Initializes a session so subsequent calls are allowed. */ +const initialized = async (dispatcher: McpDispatcher, session: McpSession): Promise => { + await dispatcher.dispatch(session, request('initialize')) +} + +describe('McpDispatcher', () => { + it('negotiates a supported protocol version', async () => { + const result = await makeDispatcher(registryWithEcho()).dispatch( + makeSession(), + request('initialize', { protocolVersion: '2025-03-26' }) + ) + expect(result).toMatchObject({ result: { protocolVersion: '2025-03-26' } }) + }) + + it('falls back to its own latest version for an unknown one', async () => { + const result = await makeDispatcher(registryWithEcho()).dispatch( + makeSession(), + request('initialize', { protocolVersion: '1999-01-01' }) + ) + expect(result).toMatchObject({ result: { protocolVersion: '2025-06-18' } }) + }) + + it('advertises tool capability and server info', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const result = await dispatcher.dispatch(makeSession(), request('initialize')) + const payload = (result as { result: Record }).result + expect(payload.capabilities).toEqual({ tools: { listChanged: false } }) + expect(payload.serverInfo).toEqual({ name: 'huly-mcp', version: '0.7.0' }) + expect(payload.instructions).toBe('hello') + }) + + it('refuses any method other than initialize before the handshake', async () => { + const result = await makeDispatcher(registryWithEcho()).dispatch(makeSession(), request('tools/list')) + expect(result).toMatchObject({ error: { code: -32600 } }) + }) + + it('allows tools/list once initialized', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const session = makeSession() + await initialized(dispatcher, session) + + const result = await dispatcher.dispatch(session, request('tools/list')) + const tools = (result as { result: { tools: Array> } }).result.tools + expect(tools).toHaveLength(1) + expect(tools[0].name).toBe('echo') + expect(tools[0].annotations).toMatchObject({ readOnlyHint: true }) + }) + + it('returns no response for a notification', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const session = makeSession() + await initialized(dispatcher, session) + + const result = await dispatcher.dispatch(session, { jsonrpc: '2.0', method: 'notifications/initialized' }) + expect(result).toBeNull() + expect(session.initialized).toBe(true) + }) + + it('answers ping with an empty result', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const session = makeSession() + await initialized(dispatcher, session) + expect(await dispatcher.dispatch(session, request('ping'))).toEqual({ jsonrpc: '2.0', id: 1, result: {} }) + }) + + it('reports an unknown method as method-not-found', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const session = makeSession() + await initialized(dispatcher, session) + expect(await dispatcher.dispatch(session, request('nope/nope'))).toMatchObject({ error: { code: -32601 } }) + }) + + it('rejects a malformed envelope with a null id', async () => { + const result = await makeDispatcher(registryWithEcho()).dispatch(makeSession(), { hello: 'world' }) + expect(result).toMatchObject({ id: null, error: { code: -32600 } }) + }) + + it('requires a name for tools/call', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const session = makeSession() + await initialized(dispatcher, session) + expect(await dispatcher.dispatch(session, request('tools/call', {}))).toMatchObject({ + error: { code: -32602 } + }) + }) + + it('runs a tool and returns its content', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const session = makeSession() + await initialized(dispatcher, session) + + const result = await dispatcher.dispatch( + session, + request('tools/call', { name: 'echo', arguments: { value: 'hi' } }) + ) + expect(result).toMatchObject({ result: { content: [{ type: 'text', text: 'hi' }] } }) + }) + + it('surfaces a tool failure as an isError result, not a protocol error', async () => { + const failing = new ToolRegistry().register({ + name: 'boom', + title: 'Boom', + description: 'always fails', + readOnly: true, + inputSchema: { type: 'object' }, + handler: async () => { + throw new Error('kaboom') + } + }) + const dispatcher = makeDispatcher(failing) + const session = makeSession() + await initialized(dispatcher, session) + + const result = (await dispatcher.dispatch(session, request('tools/call', { name: 'boom' }))) as { + error?: unknown + result: { isError: boolean, content: Array<{ text: string }> } + } + expect(result.error).toBeUndefined() + expect(result.result.isError).toBe(true) + expect(result.result.content[0].text).toContain('kaboom') + }) + + it('lists empty resources and prompts rather than erroring', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const session = makeSession() + await initialized(dispatcher, session) + + expect(await dispatcher.dispatch(session, request('resources/list'))).toMatchObject({ + result: { resources: [] } + }) + expect(await dispatcher.dispatch(session, request('prompts/list'))).toMatchObject({ + result: { prompts: [] } + }) + }) +}) diff --git a/pods/mcp/src/mcp/__tests__/protocol.test.ts b/pods/mcp/src/mcp/__tests__/protocol.test.ts new file mode 100644 index 0000000000..c42e23aa54 --- /dev/null +++ b/pods/mcp/src/mcp/__tests__/protocol.test.ts @@ -0,0 +1,93 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { errorResponse, isJsonRpcNotification, isJsonRpcRequest, isJsonRpcId, successResponse } from '../protocol' + +describe('isJsonRpcId', () => { + it('accepts strings and finite numbers only', () => { + expect(isJsonRpcId(1)).toBe(true) + expect(isJsonRpcId('abc')).toBe(true) + expect(isJsonRpcId(Number.NaN)).toBe(false) + expect(isJsonRpcId(null)).toBe(false) + expect(isJsonRpcId({})).toBe(false) + }) +}) + +describe('isJsonRpcRequest', () => { + it('accepts a well-formed request', () => { + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'ping' })).toBe(true) + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 'a', method: 'ping', params: {} })).toBe(true) + }) + + it('rejects anything that is not a JSON-RPC 2.0 envelope', () => { + // Regression: an `&&`/`:?` precedence mistake once made every one of these + // validate, which let arbitrary objects through as requests. + expect(isJsonRpcRequest({ hello: 'world' })).toBe(false) + expect(isJsonRpcRequest({})).toBe(false) + expect(isJsonRpcRequest({ jsonrpc: '1.0', id: 1, method: 'ping' })).toBe(false) + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1 })).toBe(false) + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: '' })).toBe(false) + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 42 })).toBe(false) + }) + + it('requires an id, since a message without one is a notification', () => { + expect(isJsonRpcRequest({ jsonrpc: '2.0', method: 'ping' })).toBe(false) + }) + + it('rejects non-objects and arrays', () => { + expect(isJsonRpcRequest(null)).toBe(false) + expect(isJsonRpcRequest('ping')).toBe(false) + expect(isJsonRpcRequest(42)).toBe(false) + expect(isJsonRpcRequest([{ jsonrpc: '2.0', id: 1, method: 'ping' }])).toBe(false) + }) + + it('rejects a non-structured params', () => { + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'ping', params: 'x' })).toBe(false) + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'ping', params: null })).toBe(false) + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'ping', params: [] })).toBe(false) + }) +}) + +describe('isJsonRpcNotification', () => { + it('accepts a method with no id', () => { + expect(isJsonRpcNotification({ jsonrpc: '2.0', method: 'notifications/initialized' })).toBe(true) + }) + + it('rejects anything carrying an id', () => { + expect(isJsonRpcNotification({ jsonrpc: '2.0', id: 1, method: 'ping' })).toBe(false) + expect(isJsonRpcNotification({ jsonrpc: '2.0', id: null, method: 'ping' })).toBe(false) + }) + + it('rejects garbage', () => { + expect(isJsonRpcNotification({ hello: 'world' })).toBe(false) + expect(isJsonRpcNotification(null)).toBe(false) + expect(isJsonRpcNotification('notifications/initialized')).toBe(false) + }) +}) + +describe('response builders', () => { + it('builds a success envelope', () => { + expect(successResponse(7, { ok: true })).toEqual({ jsonrpc: '2.0', id: 7, result: { ok: true } }) + }) + + it('builds an error envelope and omits absent data', () => { + expect(errorResponse(7, -32601, 'nope')).toEqual({ + jsonrpc: '2.0', + id: 7, + error: { code: -32601, message: 'nope' } + }) + expect(errorResponse(null, -32700, 'bad', { at: 1 }).error.data).toEqual({ at: 1 }) + }) +}) diff --git a/pods/mcp/src/mcp/__tests__/registry-and-session.test.ts b/pods/mcp/src/mcp/__tests__/registry-and-session.test.ts new file mode 100644 index 0000000000..841f22bc15 --- /dev/null +++ b/pods/mcp/src/mcp/__tests__/registry-and-session.test.ts @@ -0,0 +1,168 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { fakeIdentity, fakeMeasureContext, fakeToolContext, fakeWorkspaceSession } from '../../__tests__/test-doubles' +import { McpDispatcher } from '../dispatcher' +import { objectSchema } from '../schema' +import { SessionStore } from '../session' +import { type HulyTool, ToolRegistry } from '../tool' + +const ctx = fakeMeasureContext() + +const echoTool: HulyTool = { + name: 'echo', + title: 'Echo', + description: 'echoes', + readOnly: true, + inputSchema: objectSchema({ value: { type: 'string' } }), + handler: async (_c, args) => ({ content: [{ type: 'text', text: String(args.value) }] }) +} + +const writeTool: HulyTool = { + name: 'write', + title: 'Write', + description: 'writes', + readOnly: false, + inputSchema: objectSchema({}), + handler: async () => ({ content: [{ type: 'text', text: 'written' }] }) +} + +describe('ToolRegistry', () => { + it('refuses a duplicate name rather than shadowing silently', () => { + const registry = new ToolRegistry().register(echoTool) + expect(() => registry.register({ ...echoTool })).toThrow(/already registered/) + expect(registry.size).toBe(1) + }) + + it('exposes MCP annotations', () => { + const listed = new ToolRegistry().registerAll([echoTool, writeTool]).list() + expect(listed[0].annotations).toEqual({ readOnlyHint: true, destructiveHint: false, idempotentHint: true }) + expect(listed[1].annotations?.readOnlyHint).toBe(false) + }) + + it('rejects an unknown tool with the list of valid ones', async () => { + const registry = new ToolRegistry().register(echoTool) + const result = await registry.call('nope', {}, fakeToolContext()) + expect(result.isError).toBe(true) + expect(result.content[0].text).toContain('echo') + }) + + it('rejects invalid arguments before the handler runs', async () => { + const registry = new ToolRegistry().register(echoTool) + const result = await registry.call('echo', { value: 42 }, fakeToolContext()) + expect(result.isError).toBe(true) + expect(result.content[0].text).toContain('must be of type string') + }) + + it('blocks a write tool for a read-only identity', async () => { + const registry = new ToolRegistry().registerAll([echoTool, writeTool]) + const identity = fakeIdentity({ readOnly: true }) + const result = await registry.call('write', {}, fakeToolContext(identity)) + expect(result.isError).toBe(true) + expect(result.content[0].text).toContain('read-only') + }) +}) + +describe('SessionStore', () => { + it('creates, finds and deletes a session', () => { + const store = new SessionStore({ ctx, generateId: () => 'fixed' }) + const created = store.create(fakeIdentity()) + expect(created.id).toBe('fixed') + expect(store.get('fixed')).toBe(created) + expect(store.delete('fixed')).toBe(true) + expect(store.get('fixed')).toBeUndefined() + expect(store.delete('fixed')).toBe(false) + }) + + it('generates unique ids by default', () => { + const store = new SessionStore({ ctx }) + const ids = new Set(Array.from({ length: 50 }, () => store.create(fakeIdentity()).id)) + expect(ids.size).toBe(50) + }) + + it('drops sessions idle beyond the TTL', () => { + let now = 0 + const store = new SessionStore({ ctx, idleTtlMs: 100, now: () => now }) + const session = store.create(fakeIdentity()) + + // 50ms later the session is still well inside the window. + now = 50 + expect(store.sweep()).toBe(0) + expect(store.get(session.id)).toBeDefined() + + // Reading it refreshed lastSeen to 50, so it survives until 50 + TTL. + now = 140 + expect(store.sweep()).toBe(0) + + now = 160 + expect(store.sweep()).toBe(1) + expect(store.size).toBe(0) + }) + + it('evicts the least recently used session when full', () => { + let now = 0 + const store = new SessionStore({ ctx, maxSessions: 2, now: () => now }) + const first = store.create(fakeIdentity()) + now = 1 + store.create(fakeIdentity()) + now = 2 + store.get(first.id) + now = 3 + store.create(fakeIdentity()) + + expect(store.get(first.id)).toBeDefined() + expect(store.size).toBe(2) + }) + + it('binds a session to one identity', () => { + const store = new SessionStore({ ctx }) + const session = store.create(fakeIdentity()) + expect(session.belongsTo(fakeIdentity())).toBe(true) + expect(session.belongsTo(fakeIdentity({ account: 'other' as never }))).toBe(false) + expect(session.belongsTo(fakeIdentity({ workspace: 'other' as never }))).toBe(false) + }) +}) + +describe('session and dispatcher integration', () => { + it('resolves the workspace client lazily, only when a tool is called', async () => { + const registry = new ToolRegistry().register(echoTool) + const store = new SessionStore({ ctx, generateId: () => 's1' }) + const session = store.create(fakeIdentity()) + + let resolved = 0 + const dispatcher = new McpDispatcher({ + ctx, + registry, + serverName: 'huly-mcp', + serverVersion: '0.7.0', + resolveSession: async (s) => { + resolved += 1 + return fakeWorkspaceSession(s.identity) + } + }) + + await dispatcher.dispatch(session, { jsonrpc: '2.0', id: 1, method: 'initialize' }) + await dispatcher.dispatch(session, { jsonrpc: '2.0', id: 2, method: 'tools/list' }) + expect(resolved).toBe(0) + + await dispatcher.dispatch(session, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'echo', arguments: { value: 'x' } } + }) + expect(resolved).toBe(1) + }) +}) diff --git a/pods/mcp/src/mcp/__tests__/validation.test.ts b/pods/mcp/src/mcp/__tests__/validation.test.ts new file mode 100644 index 0000000000..9f76c53fae --- /dev/null +++ b/pods/mcp/src/mcp/__tests__/validation.test.ts @@ -0,0 +1,117 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type JsonSchema, objectSchema } from '../schema' +import { type ValidationResult, validateArguments } from '../validation' + +const schema: JsonSchema = { + type: 'object', + additionalProperties: false, + properties: { + title: { type: 'string', minLength: 1, maxLength: 10 }, + count: { type: 'integer', minimum: 1, maximum: 5 }, + mode: { type: 'string', enum: ['a', 'b'] }, + flag: { type: 'boolean', default: false }, + tags: { type: 'array', items: { type: 'string' }, maxItems: 2 }, + nested: { type: 'object', properties: { deep: { type: 'number' } }, required: ['deep'] } + }, + required: ['title'] +} + +/** + * `ok` is compared explicitly rather than used as a bare condition. + * + * The repo's ESLint enables `strict-boolean-expressions`, and an explicit + * comparison also documents intent: these assertions care about *which* branch + * they took, not merely whether validation passed. + */ +const issuesOf = (result: ValidationResult): string[] => (result.ok === true ? [] : result.issues) + +const valueOf = (result: ValidationResult): Record => (result.ok === true ? result.value : {}) + +const isOk = (result: ValidationResult): boolean => result.ok === true + +describe('validateArguments', () => { + it('accepts a minimal valid payload', () => { + expect(isOk(validateArguments(schema, { title: 'ok' }))).toBe(true) + }) + + it('reports every problem at once so the model can fix them in one round trip', () => { + const issues = issuesOf(validateArguments(schema, { count: 99, mode: 'z' })) + expect(issues).toHaveLength(3) + expect(issues.join(' ')).toContain('title is required') + expect(issues.join(' ')).toContain('count') + expect(issues.join(' ')).toContain('mode') + }) + + it('rejects unknown properties when additionalProperties is false', () => { + const issues = issuesOf(validateArguments(schema, { title: 'ok', sneaky: 1 })) + expect(issues[0]).toContain('sneaky is not an accepted property') + }) + + it('applies declared defaults', () => { + expect(valueOf(validateArguments(schema, { title: 'ok' })).flag).toBe(false) + }) + + it('treats undefined as absent rather than invalid', () => { + expect(isOk(validateArguments(schema, { title: 'ok', count: undefined }))).toBe(true) + }) + + it('treats a whole-number float as an integer', () => { + expect(isOk(validateArguments(schema, { title: 'ok', count: 3.0 }))).toBe(true) + }) + + it('rejects a fractional value for an integer field', () => { + expect(isOk(validateArguments(schema, { title: 'ok', count: 3.5 }))).toBe(false) + }) + + it('enforces string length bounds', () => { + expect(isOk(validateArguments(schema, { title: '' }))).toBe(false) + expect(isOk(validateArguments(schema, { title: 'far too long' }))).toBe(false) + }) + + it('validates array items and their length', () => { + expect(isOk(validateArguments(schema, { title: 'ok', tags: ['a'] }))).toBe(true) + expect(isOk(validateArguments(schema, { title: 'ok', tags: ['a', 'b', 'c'] }))).toBe(false) + expect(isOk(validateArguments(schema, { title: 'ok', tags: ['a', 5] }))).toBe(false) + }) + + it('validates nested objects and their required fields', () => { + expect(isOk(validateArguments(schema, { title: 'ok', nested: { deep: 1 } }))).toBe(true) + const issues = issuesOf(validateArguments(schema, { title: 'ok', nested: {} })) + expect(issues.join(' ')).toContain('nested.deep is required') + }) + + it('treats a null or absent body as an empty object', () => { + // `title` is required, so an empty object still fails — but only for that + // reason, not because the body itself was rejected. + expect(issuesOf(validateArguments(schema, null))).toEqual(['title is required']) + expect(isOk(validateArguments(objectSchema({}), null))).toBe(true) + expect(isOk(validateArguments(objectSchema({}), undefined))).toBe(true) + }) + + it('rejects a body that is not an object', () => { + expect(isOk(validateArguments(schema, []))).toBe(false) + expect(isOk(validateArguments(schema, 'nope'))).toBe(false) + expect(isOk(validateArguments(schema, 7))).toBe(false) + }) + + it('checks numeric bounds', () => { + expect(isOk(validateArguments(schema, { title: 'ok', count: 1 }))).toBe(true) + expect(isOk(validateArguments(schema, { title: 'ok', count: 5 }))).toBe(true) + expect(isOk(validateArguments(schema, { title: 'ok', count: 0 }))).toBe(false) + expect(isOk(validateArguments(schema, { title: 'ok', count: 6 }))).toBe(false) + }) +}) diff --git a/pods/mcp/src/mcp/dispatcher.ts b/pods/mcp/src/mcp/dispatcher.ts new file mode 100644 index 0000000000..514f419c1d --- /dev/null +++ b/pods/mcp/src/mcp/dispatcher.ts @@ -0,0 +1,191 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type MeasureContext } from '@hcengineering/core' + +import { type WorkspaceSession } from '../platform/workspace-client-provider' + +import { + ErrorCode, + isJsonRpcNotification, + isJsonRpcRequest, + LATEST_PROTOCOL_VERSION, + RpcError, + SUPPORTED_PROTOCOL_VERSIONS, + type JsonRpcRequest, + type JsonRpcResponseMessage, + successResponse +} from './protocol' +import { type McpSession } from './session' +import { toolContext, type ToolRegistry } from './tool' + +export interface McpDispatcherOptions { + ctx: MeasureContext + registry: ToolRegistry + serverName: string + serverVersion: string + /** Free-form guidance shown to the model after `initialize`. */ + instructions?: string + /** + * Resolves the workspace client for the session. Injected rather than + * constructed so the dispatcher stays free of platform dependencies and can be + * tested with a fake. + */ + resolveSession: (session: McpSession) => Promise +} + +interface InitializeResult { + protocolVersion: string + capabilities: Record + serverInfo: { name: string, version: string } + instructions?: string +} + +const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0 + +/** + * Routes MCP JSON-RPC methods to handlers. + * + * Knows nothing about HTTP, SSE or sessions-on-the-wire: it takes a message and + * a session and returns a response (or `null` for notifications). That keeps the + * protocol rules unit-testable without a socket, and lets the transport layer + * change without touching any MCP semantics. + */ +export class McpDispatcher { + private readonly ctx: MeasureContext + private readonly registry: ToolRegistry + private readonly serverName: string + private readonly serverVersion: string + private readonly instructions: string | undefined + private readonly resolveSession: McpDispatcherOptions['resolveSession'] + + constructor (options: McpDispatcherOptions) { + this.ctx = options.ctx + this.registry = options.registry + this.serverName = options.serverName + this.serverVersion = options.serverVersion + this.instructions = options.instructions + this.resolveSession = options.resolveSession + } + + /** + * @returns a response, or `null` when the message was a notification (which + * by JSON-RPC rules must not be answered). + */ + async dispatch (session: McpSession, message: unknown): Promise { + if (isJsonRpcNotification(message)) { + await this.handleNotification(session, message.method) + return null + } + + if (!isJsonRpcRequest(message)) { + return { + jsonrpc: '2.0', + id: null, + error: { code: ErrorCode.InvalidRequest, message: 'Not a valid JSON-RPC 2.0 request' } + } + } + + const request = message as JsonRpcRequest + + // Guard every method except initialize: a client that skips the handshake + // would otherwise be able to call tools with no negotiated protocol version. + if (request.method !== 'initialize' && session.protocolVersion === undefined) { + return this.fail(request.id, ErrorCode.InvalidRequest, 'Session is not initialized') + } + + try { + const result = await this.handleRequest(session, request) + return successResponse(request.id, result) + } catch (err) { + if (err instanceof RpcError) { + return { jsonrpc: '2.0', id: request.id, error: { code: err.code, message: err.message } } + } + const error = err as Error + this.ctx.error('mcp dispatch failed', { method: request.method, error: error?.message }) + return { + jsonrpc: '2.0', + id: request.id, + error: { code: ErrorCode.InternalError, message: 'Internal server error' } + } + } + } + + private async handleNotification (session: McpSession, method: string): Promise { + if (method === 'notifications/initialized' || method === 'initialized') { + session.initialized = true + this.ctx.info('mcp session initialized', { session: session.id }) + } + } + + private async handleRequest (session: McpSession, request: JsonRpcRequest): Promise { + switch (request.method) { + case 'initialize': + return this.initialize(session, request.params) + case 'ping': + return {} + case 'tools/list': + return { tools: this.registry.list() } + case 'tools/call': + return await this.callTool(session, request.params) + case 'resources/list': + return { resources: [] } + case 'prompts/list': + return { prompts: [] } + default: + throw new RpcError(ErrorCode.MethodNotFound, `Method not found: ${request.method}`) + } + } + + private initialize (session: McpSession, params: unknown): InitializeResult { + const requested = isNonEmptyString((params as { protocolVersion?: unknown })?.protocolVersion) + ? (params as { protocolVersion: string }).protocolVersion + : undefined + + // Per spec: echo the client's version when we support it, otherwise answer + // with our own latest and let the client decide whether to continue. + const protocolVersion = + requested !== undefined && (SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(requested) + ? requested + : LATEST_PROTOCOL_VERSION + + session.protocolVersion = protocolVersion + + return { + protocolVersion, + capabilities: { + tools: { listChanged: false } + }, + serverInfo: { name: this.serverName, version: this.serverVersion }, + ...(this.instructions === undefined ? {} : { instructions: this.instructions }) + } + } + + private async callTool (session: McpSession, params: unknown): Promise { + const args = params as { name?: unknown, arguments?: unknown } | undefined + if (!isNonEmptyString(args?.name)) { + throw new RpcError(ErrorCode.InvalidParams, 'tools/call requires a "name" parameter') + } + + const workspaceSession = await this.resolveSession(session) + const context = toolContext(workspaceSession, this.ctx.newChild(args.name, {}, { span: false })) + + return await this.registry.call(args.name, args.arguments, context) + } + + private fail (id: JsonRpcRequest['id'], code: number, message: string): JsonRpcResponseMessage { + return { jsonrpc: '2.0', id, error: { code, message } } + } +} diff --git a/pods/mcp/src/mcp/protocol.ts b/pods/mcp/src/mcp/protocol.ts new file mode 100644 index 0000000000..4f0afb77f1 --- /dev/null +++ b/pods/mcp/src/mcp/protocol.ts @@ -0,0 +1,170 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// JSON-RPC 2.0 + MCP message shapes. +// +// The MCP server side of the wire format is small and stable, so it is +// implemented here directly instead of pulling a protocol SDK (and its +// transitive zod dependency) into the monorepo. Everything below is pure data +// plus guards: no I/O, no framework coupling, trivially unit tested. + +export const JSONRPC_VERSION = '2.0' as const + +/** + * Protocol revisions this server understands, newest first. `initialize` picks + * the client's revision when it is listed here, otherwise we answer with + * LATEST_PROTOCOL_VERSION and let the client decide whether to continue. + */ +export const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26'] as const +export const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0] + +export type JsonRpcId = string | number + +export interface JsonRpcRequest { + jsonrpc: typeof JSONRPC_VERSION + id: JsonRpcId + method: string + params?: unknown +} + +export interface JsonRpcNotification { + jsonrpc: typeof JSONRPC_VERSION + method: string + params?: unknown +} + +export interface JsonRpcErrorObject { + code: number + message: string + data?: unknown +} + +export interface JsonRpcResponse { + jsonrpc: typeof JSONRPC_VERSION + id: JsonRpcId + result: unknown +} + +export interface JsonRpcErrorResponse { + jsonrpc: typeof JSONRPC_VERSION + id: JsonRpcId | null + error: JsonRpcErrorObject +} + +export type JsonRpcResponseMessage = JsonRpcResponse | JsonRpcErrorResponse + +/** Standard JSON-RPC 2.0 error codes plus the MCP/HTTP-relevant additions. */ +export const ErrorCode = { + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603 +} as const + +/** + * A JSON-RPC level failure. Anything thrown that is *not* a RpcError is + * reported to the client as InternalError, so internal messages never leak. + */ +export class RpcError extends Error { + readonly code: number + readonly data: unknown + + constructor (code: number, message: string, data?: unknown) { + super(message) + this.name = 'RpcError' + this.code = code + this.data = data + } +} + +export function isJsonRpcId (value: unknown): value is JsonRpcId { + return typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)) +} + +export function isJsonRpcRequest (value: unknown): value is JsonRpcRequest { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const candidate = value as Record + + if (candidate.jsonrpc !== JSONRPC_VERSION) return false + if (typeof candidate.method !== 'string' || candidate.method.length === 0) return false + if (!isJsonRpcId(candidate.id)) return false + + // `params` is optional; when present it must be a structured value. + if (candidate.params !== undefined) { + return typeof candidate.params === 'object' && candidate.params !== null && !Array.isArray(candidate.params) + } + return true +} + +export function isJsonRpcNotification (value: unknown): value is JsonRpcNotification { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const candidate = value as Record + + if (candidate.jsonrpc !== JSONRPC_VERSION) return false + if (typeof candidate.method !== 'string' || candidate.method.length === 0) return false + // The absence of `id` is what makes it a notification rather than a request. + return candidate.id === undefined +} + +export function successResponse (id: JsonRpcId, result: unknown): JsonRpcResponse { + return { jsonrpc: JSONRPC_VERSION, id, result } +} + +export function errorResponse ( + id: JsonRpcId | null, + code: number, + message: string, + data?: unknown +): JsonRpcErrorResponse { + return { jsonrpc: JSONRPC_VERSION, id, error: { code, message, ...(data === undefined ? {} : { data }) } } +} + +/* -------------------------------------------------------------------------- */ +/* MCP content blocks */ +/* -------------------------------------------------------------------------- */ + +export interface McpTextContent { + type: 'text' + text: string +} + +export type McpContentBlock = McpTextContent + +export interface McpToolCallResult { + content: McpContentBlock[] + isError?: boolean + structuredContent?: Record +} + +export function textResult (text: string, structuredContent?: Record): McpToolCallResult { + return { + content: [{ type: 'text', text }], + ...(structuredContent === undefined ? {} : { structuredContent }) + } +} + +export function errorResult (message: string): McpToolCallResult { + return { content: [{ type: 'text', text: message }], isError: true } +} + +export function isMcpTextContent (value: unknown): value is McpTextContent { + return ( + typeof value === 'object' && + value !== null && + (value as Record).type === 'text' && + typeof (value as Record).text === 'string' + ) +} diff --git a/pods/mcp/src/mcp/schema.ts b/pods/mcp/src/mcp/schema.ts new file mode 100644 index 0000000000..3d122cef8b --- /dev/null +++ b/pods/mcp/src/mcp/schema.ts @@ -0,0 +1,91 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// The subset of JSON Schema that MCP tool `inputSchema` values use. +// +// This is intentionally a closed subset: tool authors get a small surface with +// good error messages instead of a general purpose JSON Schema engine. The +// validator in ./validation.ts and the types in this file must stay in sync — +// anything not listed here is rejected by validation instead of silently +// ignored, which is what keeps tools and validators from drifting apart. + +export type JsonSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null' + +export interface JsonSchema { + type?: JsonSchemaType + description?: string + title?: string + + properties?: Record + required?: string[] + additionalProperties?: boolean + + items?: JsonSchema + + enum?: Array + + default?: unknown + + minimum?: number + maximum?: number + minLength?: number + maxLength?: number + minItems?: number + maxItems?: number + pattern?: string + + format?: string +} + +export type ToolArguments = Record + +/** Shape returned to clients by `tools/list`. */ +export interface McpToolDescriptor { + name: string + title: string + description: string + inputSchema: JsonSchema + annotations?: { + readOnlyHint?: boolean + destructiveHint?: boolean + idempotentHint?: boolean + } +} + +/** Small helpers so tool definitions read declaratively and stay DRY. */ +export const objectSchema = (properties: Record, required: string[] = []): JsonSchema => ({ + type: 'object', + properties, + required, + additionalProperties: false +}) + +export const stringProp = (description: string, extra: Partial = {}): JsonSchema => ({ + type: 'string', + description, + ...extra +}) + +export const numberProp = (description: string, extra: Partial = {}): JsonSchema => ({ + type: 'number', + description, + ...extra +}) + +export const booleanProp = (description: string, extra: Partial = {}): JsonSchema => ({ + type: 'boolean', + description, + ...extra +}) diff --git a/pods/mcp/src/mcp/session.ts b/pods/mcp/src/mcp/session.ts new file mode 100644 index 0000000000..7b7dc55924 --- /dev/null +++ b/pods/mcp/src/mcp/session.ts @@ -0,0 +1,155 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type MeasureContext } from '@hcengineering/core' +import { randomUUID as cryptoRandomUUID } from 'node:crypto' + +import { type SessionIdentity } from '../auth/authenticator' + +/** + * One MCP client conversation. + * + * The identity is resolved at `initialize` and never changes afterwards. A + * request carrying a different identity than the session's is rejected by the + * transport, so a session id cannot be replayed under another account. + */ +export class McpSession { + readonly id: string + readonly identity: SessionIdentity + readonly createdOn: number + protocolVersion: string | undefined + initialized = false + lastSeen: number + + constructor (id: string, identity: SessionIdentity, now: number) { + this.id = id + this.identity = identity + this.createdOn = now + this.lastSeen = now + } + + touch (now: number): void { + this.lastSeen = now + } + + belongsTo (identity: SessionIdentity): boolean { + return ( + this.identity.account === identity.account && this.identity.workspace === identity.workspace + ) + } +} + +export interface SessionStoreOptions { + ctx: MeasureContext + /** Idle time after which a session is dropped, in milliseconds. */ + idleTtlMs?: number + /** Maximum concurrent sessions, to bound memory. */ + maxSessions?: number + now?: () => number + generateId?: () => string +} + +const DEFAULT_IDLE_TTL_MS = 30 * 60_000 +const DEFAULT_MAX_SESSIONS = 1000 + +/** + * In-memory session store. + * + * Deliberately not persisted: a restart drops all sessions, and clients recover + * by re-initializing. Session state here is a cache of an authenticated + * identity, not a source of truth, so there is nothing worth writing to disk. + */ +export class SessionStore { + private readonly ctx: MeasureContext + private readonly sessions = new Map() + private readonly idleTtlMs: number + private readonly maxSessions: number + private readonly now: () => number + private readonly generateId: () => string + + constructor (options: SessionStoreOptions) { + this.ctx = options.ctx + this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS + this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS + this.now = options.now ?? Date.now + this.generateId = options.generateId ?? (() => randomUUID()) + } + + create (identity: SessionIdentity): McpSession { + if (this.sessions.size >= this.maxSessions) { + // Evict the least recently used rather than refuse service: an agent + // reconnecting after a network blip should not be locked out. + this.evictOldest() + } + + const session = new McpSession(this.generateId(), identity, this.now()) + this.sessions.set(session.id, session) + this.ctx.info('mcp session opened', { session: session.id, workspace: identity.workspace }) + return session + } + + get (id: string): McpSession | undefined { + const session = this.sessions.get(id) + if (session === undefined) return undefined + session.touch(this.now()) + return session + } + + delete (id: string): boolean { + const session = this.sessions.get(id) + if (session === undefined) return false + this.sessions.delete(id) + this.ctx.info('mcp session closed', { session: id }) + return true + } + + /** Drops idle sessions. Returns how many were removed. */ + sweep (): number { + const cutoff = this.now() - this.idleTtlMs + let removed = 0 + for (const [id, session] of [...this.sessions]) { + if (session.lastSeen <= cutoff) { + this.sessions.delete(id) + removed += 1 + } + } + return removed + } + + private evictOldest (): void { + let oldest: McpSession | undefined + for (const session of this.sessions.values()) { + if (oldest === undefined || session.lastSeen < oldest.lastSeen) oldest = session + } + if (oldest !== undefined) this.sessions.delete(oldest.id) + } + + get size (): number { + return this.sessions.size + } + + closeAll (): void { + this.sessions.clear() + } +} + +/** + * Session ids are bearer-equivalent: whoever holds one inherits the session's + * authenticated identity, so they must be unguessable. `crypto.randomUUID` is + * the right primitive here, not a hand-rolled Math.random. + */ +function randomUUID (): string { + return cryptoRandomUUID() +} diff --git a/pods/mcp/src/mcp/streamable-http.ts b/pods/mcp/src/mcp/streamable-http.ts new file mode 100644 index 0000000000..1dea9a4561 --- /dev/null +++ b/pods/mcp/src/mcp/streamable-http.ts @@ -0,0 +1,258 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type MeasureContext } from '@hcengineering/core' +import { type Request, type Response } from 'express' + +import { AuthenticationError, type Authenticator, type SessionIdentity } from '../auth/authenticator' +import { extractBearerToken } from '../auth/token-extractor' +import { type McpDispatcher } from './dispatcher' +import { ErrorCode, isJsonRpcRequest } from './protocol' +import { type McpSession, type SessionStore } from './session' + +export const SESSION_HEADER = 'mcp-session-id' +export const PROTOCOL_VERSION_HEADER = 'mcp-protocol-version' + +/** Content types a client must be willing to accept on a POST. */ +const ACCEPTABLE = ['application/json', 'text/event-stream'] as const + +export interface StreamableHttpOptions { + ctx: MeasureContext + store: SessionStore + dispatcher: McpDispatcher + authenticator: Authenticator + /** Interval between SSE keepalive comments, in milliseconds. */ + keepAliveIntervalMs?: number +} + +const DEFAULT_KEEPALIVE_MS = 25_000 + +/** + * The MCP "Streamable HTTP" transport. + * + * One endpoint, three verbs: + * POST — client to server JSON-RPC (initialize, tools/call, ...) + * GET — server to client SSE stream (keepalive today, notifications later) + * DELETE — terminate the session + * + * The transport owns authentication, session binding and the HTTP status codes; + * all MCP semantics live in the dispatcher. + */ +export class StreamableHttpTransport { + private readonly ctx: MeasureContext + private readonly store: SessionStore + private readonly dispatcher: McpDispatcher + private readonly authenticator: Authenticator + private readonly keepAliveIntervalMs: number + private readonly streams = new Set() + + constructor (options: StreamableHttpOptions) { + this.ctx = options.ctx + this.store = options.store + this.dispatcher = options.dispatcher + this.authenticator = options.authenticator + this.keepAliveIntervalMs = options.keepAliveIntervalMs ?? DEFAULT_KEEPALIVE_MS + } + + handlePost = async (req: Request, res: Response): Promise => { + if (!this.acceptsSupportedContent(req)) { + res.status(406).json({ + jsonrpc: '2.0', + id: null, + error: { + code: ErrorCode.InvalidRequest, + message: `Not Acceptable: clients must accept ${ACCEPTABLE.join(' or ')}` + } + }) + return + } + + let identity: SessionIdentity + try { + identity = await this.authenticate(req) + } catch (err) { + this.sendAuthError(res, err) + return + } + + const body: unknown = req.body + const isInitialize = isJsonRpcRequest(body) && body.method === 'initialize' + + let session: McpSession | undefined + const sessionId = headerValue(req, SESSION_HEADER) + + if (sessionId !== undefined) { + session = this.store.get(sessionId) + if (session === undefined) { + // A stale id after a server restart is normal. Reporting 404 (rather + // than silently creating a new session) makes the client re-initialize + // instead of continuing against a session it believes is authenticated. + res.status(404).json({ error: 'Session not found or expired, re-initialize' }) + return + } + if (!session.belongsTo(identity)) { + this.ctx.warn('mcp session identity mismatch', { session: session.id }) + res.status(403).json({ error: 'Session belongs to a different account or workspace' }) + return + } + } else if (!isInitialize) { + res.status(400).json({ error: `Missing ${SESSION_HEADER} header` }) + return + } + + if (isInitialize && session === undefined) { + session = this.store.create(identity) + res.setHeader(SESSION_HEADER, session.id) + } + + const active = session as McpSession + const response = await this.dispatcher.dispatch(active, body) + + if (response === null) { + // Notification acknowledged, no body — JSON-RPC forbids responding. + res.status(202).end() + return + } + + res.status(200).json(response) + } + + handleGet = async (req: Request, res: Response): Promise => { + const sessionId = headerValue(req, SESSION_HEADER) + if (sessionId === undefined) { + res.status(400).json({ error: `Missing ${SESSION_HEADER} header` }) + return + } + + if (!(await this.authorizeSession(req, res, sessionId))) return + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + // Nginx buffers SSE by default, which stalls server-initiated messages. + 'X-Accel-Buffering': 'no' + }) + res.flushHeaders?.() + + this.streams.add(res) + const keepAlive = setInterval(() => { + // An SSE comment keeps proxies and clients from reaping an idle stream. + res.write(': keepalive\n\n') + }, this.keepAliveIntervalMs) + keepAlive.unref?.() + + const cleanup = (): void => { + clearInterval(keepAlive) + this.streams.delete(res) + } + req.on('close', cleanup) + res.on('close', cleanup) + } + + handleDelete = async (req: Request, res: Response): Promise => { + const sessionId = headerValue(req, SESSION_HEADER) + if (sessionId === undefined) { + res.status(400).json({ error: `Missing ${SESSION_HEADER} header` }) + return + } + + if (!(await this.authorizeSession(req, res, sessionId))) return + + this.store.delete(sessionId) + res.status(204).end() + } + + /** + * Authenticates and confirms the session belongs to the caller. + * + * Shared by GET and DELETE so a session id can never be used by, or ended by, + * a different account. + * + * @returns true when the request may proceed; otherwise the response has + * already been written. + */ + private async authorizeSession (req: Request, res: Response, sessionId: string): Promise { + let identity: SessionIdentity + try { + identity = await this.authenticate(req) + } catch (err) { + this.sendAuthError(res, err) + return false + } + + const session = this.store.get(sessionId) + if (session === undefined) { + res.status(404).json({ error: 'Session not found or expired' }) + return false + } + if (!session.belongsTo(identity)) { + this.ctx.warn('mcp session identity mismatch', { session: session.id }) + res.status(403).json({ error: 'Session belongs to a different account or workspace' }) + return false + } + + return true + } + + private async authenticate (req: Request): Promise { + const rawToken = extractBearerToken(req.headers) + if (rawToken === undefined) { + // In `configured` mode the authenticator ignores the token entirely, so a + // missing header is only fatal for per-request auth. The concrete + // authenticators decide; this only guarantees a string to hand them. + return await this.authenticator.authenticate('') + } + return await this.authenticator.authenticate(rawToken) + } + + private sendAuthError (res: Response, err: unknown): void { + const reason = err instanceof AuthenticationError ? err.reason : 'invalid' + const message = err instanceof Error ? err.message : 'Unauthorized' + + if (reason === 'missing') { + res.setHeader('WWW-Authenticate', 'Bearer realm="huly-mcp"') + } + + this.ctx.warn('mcp request unauthorized', { reason, message }) + res.status(401).json({ error: message }) + } + + private acceptsSupportedContent (req: Request): boolean { + const accept = req.headers.accept + // An absent Accept header means "anything", which is legal HTTP. + if (accept === undefined) return true + return ACCEPTABLE.some((type) => accept.includes(type)) + } + + /** Closes every open SSE stream; used on shutdown. */ + closeAll (): void { + for (const res of this.streams) { + try { + res.end() + } catch { + // Already gone. + } + } + this.streams.clear() + } +} + +function headerValue (req: Request, name: string): string | undefined { + const value = req.headers[name] + if (Array.isArray(value)) return value[0] + if (typeof value === 'string' && value.length > 0) return value + return undefined +} diff --git a/pods/mcp/src/mcp/tool.ts b/pods/mcp/src/mcp/tool.ts new file mode 100644 index 0000000000..4f2fbf8e1e --- /dev/null +++ b/pods/mcp/src/mcp/tool.ts @@ -0,0 +1,146 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type AccountUuid, type MeasureContext, type WorkspaceUuid } from '@hcengineering/core' + +import { type WorkspaceSession } from '../platform/workspace-client-provider' +import { errorResult, type McpToolCallResult } from './protocol' +import { type JsonSchema, type McpToolDescriptor, type ToolArguments } from './schema' +import { validateArguments } from './validation' + +/** Everything a tool handler is allowed to touch. */ +export interface ToolContext extends WorkspaceSession { + /** Per-request child context, so tool spans are attributed correctly. */ + ctx: MeasureContext + account: AccountUuid + workspace: WorkspaceUuid + readOnly: boolean +} + +export interface HulyTool { + name: string + title: string + description: string + /** + * Read-only tools are the default. A tool must opt in to writing by setting + * this to false, which also makes it unavailable to read-only tokens. + */ + readOnly: boolean + /** True when a call can destroy data; surfaced as the MCP destructive hint. */ + destructive?: boolean + inputSchema: JsonSchema + handler: (ctx: ToolContext, args: ToolArguments) => Promise +} + +export function toolContext (session: WorkspaceSession, ctx: MeasureContext): ToolContext { + return { + ...session, + ctx, + account: session.identity.account, + workspace: session.identity.workspace, + readOnly: session.identity.readOnly + } +} + +/** + * Registry of MCP tools. + * + * Registration fails fast on duplicates: a silently shadowed tool is the kind + * of bug that only shows up as "the agent called the wrong thing". + */ +export class ToolRegistry { + private readonly tools = new Map() + + register (tool: HulyTool): this { + if (this.tools.has(tool.name)) { + throw new Error(`Tool ${tool.name} is already registered`) + } + this.tools.set(tool.name, tool) + return this + } + + registerAll (tools: HulyTool[]): this { + tools.forEach((tool) => this.register(tool)) + return this + } + + get (name: string): HulyTool | undefined { + return this.tools.get(name) + } + + has (name: string): boolean { + return this.tools.has(name) + } + + get size (): number { + return this.tools.size + } + + list (): McpToolDescriptor[] { + return [...this.tools.values()].map((tool) => ({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: { + readOnlyHint: tool.readOnly, + destructiveHint: tool.destructive ?? false, + idempotentHint: tool.readOnly + } + })) + } + + /** + * Validates and runs a tool. + * + * Returns (rather than throws) for the three failures that are the agent's + * fault and are worth retrying with different arguments: unknown tool, bad + * arguments, and a write attempt from a read-only token. Reporting those as + * `isError` results lets the model self-correct; the transport stays a + * successful JSON-RPC call. + */ + async call ( + name: string, + rawArgs: unknown, + context: ToolContext + ): Promise { + const tool = this.tools.get(name) + if (tool === undefined) { + const available = [...this.tools.keys()].join(', ') + return errorResult(`Unknown tool "${name}". Available tools: ${available}`) + } + + if (!tool.readOnly && context.readOnly) { + return errorResult(`Tool "${name}" modifies data and this token is read-only.`) + } + + const validation = validateArguments(tool.inputSchema, rawArgs) + if (!validation.ok) { + return errorResult(`Invalid arguments for "${name}": ${validation.issues.join('; ')}`) + } + + return await context.ctx.with(tool.name, { tool: tool.name, readOnly: tool.readOnly }, async (ctx) => { + try { + return await tool.handler({ ...context, ctx }, validation.value) + } catch (err) { + // Surfaced as a tool error, never as an HTTP 500: the agent needs the + // reason in order to try a different approach. + const message = err instanceof Error ? err.message : String(err) + ctx.error('mcp tool failed', { tool: tool.name, error: message }) + return errorResult(`Tool "${name}" failed: ${message}`) + } + }) + } +} diff --git a/pods/mcp/src/mcp/validation.ts b/pods/mcp/src/mcp/validation.ts new file mode 100644 index 0000000000..77b2897c5d --- /dev/null +++ b/pods/mcp/src/mcp/validation.ts @@ -0,0 +1,200 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type JsonSchema, type JsonSchemaType, type ToolArguments } from './schema' + +// Minimal JSON Schema validator for the closed subset declared in ./schema.ts. +// +// Tool arguments arrive from a language model, so validation is a trust +// boundary: it is the only thing standing between a hallucinated argument and +// a malformed Huly query. It therefore fails fast and reports every issue at +// once, so an agent can correct the whole call in one round trip. + +export type ValidationResult = + | { ok: true, value: ToolArguments } + | { ok: false, issues: string[] } + +const MAX_PATTERN_LENGTH = 512 +const patternCache = new Map() + +function isPlainObject (value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function typeOf (value: unknown): JsonSchemaType { + if (value === null) return 'null' + if (Array.isArray(value)) return 'array' + if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number' + if (typeof value === 'string') return 'string' + if (typeof value === 'boolean') return 'boolean' + // bigint, symbol, function and undefined can all arrive from a JS caller + // even though JSON cannot carry them; treat them all as a type mismatch. + return 'object' +} + +function matchesType (value: unknown, type: JsonSchemaType): boolean { + const actual = typeOf(value) + // A JSON `1.0` is a valid `integer` per JSON Schema. + if (type === 'number') return actual === 'number' || actual === 'integer' + if (type === 'integer') return actual === 'integer' + return actual === type +} + +function compilePattern (pattern: string): RegExp | undefined { + const cached = patternCache.get(pattern) + if (cached !== undefined) return cached + if (pattern.length > MAX_PATTERN_LENGTH) return undefined + try { + const compiled = new RegExp(pattern) + patternCache.set(pattern, compiled) + return compiled + } catch { + // An uncompilable pattern is a bug in the tool definition, not in the + // arguments. Fail fast at call time rather than rejecting every request. + throw new Error(`Tool schema declares an invalid pattern: ${pattern}`) + } +} + +/** Validates a single value against a schema node, collecting issues. */ +function validateValue (value: unknown, schema: JsonSchema, path: string, issues: string[]): void { + if (schema.type !== undefined && !matchesType(value, schema.type)) { + issues.push(`${path} must be of type ${schema.type} (received ${typeOf(value)})`) + return + } + + if (schema.enum !== undefined) { + if (!schema.enum.some((allowed) => allowed === value)) { + issues.push(`${path} must be one of: ${schema.enum.map((entry) => JSON.stringify(entry)).join(', ')}`) + } + } + + if (typeof value === 'string') { + if (schema.minLength !== undefined && value.length < schema.minLength) { + issues.push(`${path} must be at least ${schema.minLength} characters long`) + } + if (schema.maxLength !== undefined && value.length > schema.maxLength) { + issues.push(`${path} must be at most ${schema.maxLength} characters long`) + } + if (schema.pattern !== undefined) { + const regex = compilePattern(schema.pattern) + if (regex !== undefined && !regex.test(value)) { + issues.push(`${path} must match the pattern ${schema.pattern}`) + } + } + } + + if (typeof value === 'number') { + if (schema.minimum !== undefined && value < schema.minimum) { + issues.push(`${path} must be greater than or equal to ${schema.minimum}`) + } + if (schema.maximum !== undefined && value > schema.maximum) { + issues.push(`${path} must be less than or equal to ${schema.maximum}`) + } + } + + if (Array.isArray(value)) { + if (schema.minItems !== undefined && value.length < schema.minItems) { + issues.push(`${path} must contain at least ${schema.minItems} items`) + } + if (schema.maxItems !== undefined && value.length > schema.maxItems) { + issues.push(`${path} must contain at most ${schema.maxItems} items`) + } + if (schema.items !== undefined) { + value.forEach((entry, index) => { + validateValue(entry, schema.items as JsonSchema, `${path}[${index}]`, issues) + }) + } + } + + if (isPlainObject(value) && schema.properties !== undefined) { + validateObject(value, schema, path, issues) + } +} + +function validateObject ( + value: Record, + schema: JsonSchema, + path: string, + issues: string[] +): void { + const properties = schema.properties ?? {} + const required = schema.required ?? [] + + for (const key of required) { + if (value[key] === undefined) { + issues.push(`${path === '' ? key : `${path}.${key}`} is required`) + } + } + + for (const [key, entry] of Object.entries(value)) { + const childPath = path === '' ? key : `${path}.${key}` + const propertySchema = properties[key] + if (propertySchema === undefined) { + if (schema.additionalProperties === false) { + issues.push(`${childPath} is not an accepted property. Accepted: ${Object.keys(properties).join(', ')}`) + } + continue + } + if (entry === undefined) continue + validateValue(entry, propertySchema, childPath, issues) + } +} + +/** + * Validates untyped tool arguments against the tool's input schema and applies + * declared defaults, so handlers can rely on required properties being present. + */ +export function validateArguments (schema: JsonSchema, args: unknown): ValidationResult { + const issues: string[] = [] + const input = args === undefined || args === null ? {} : args + + if (!isPlainObject(input)) { + return { ok: false, issues: ['arguments must be an object'] } + } + + const properties = schema.properties ?? {} + const required = schema.required ?? [] + + for (const key of required) { + if (input[key] === undefined) { + issues.push(`${key} is required`) + } + } + + for (const [key, entry] of Object.entries(input)) { + const propertySchema = properties[key] + if (propertySchema === undefined) { + if (schema.additionalProperties === false) { + issues.push(`${key} is not an accepted property. Accepted: ${Object.keys(properties).join(', ')}`) + } + continue + } + if (entry === undefined) continue + validateValue(entry, propertySchema, key, issues) + } + + if (issues.length > 0) { + return { ok: false, issues } + } + + const value: ToolArguments = { ...input } + for (const [key, propertySchema] of Object.entries(properties)) { + if (value[key] === undefined && propertySchema.default !== undefined) { + value[key] = propertySchema.default + } + } + + return { ok: true, value } +} diff --git a/pods/mcp/src/middleware/__tests__/rate-limiter.test.ts b/pods/mcp/src/middleware/__tests__/rate-limiter.test.ts new file mode 100644 index 0000000000..7ad296495c --- /dev/null +++ b/pods/mcp/src/middleware/__tests__/rate-limiter.test.ts @@ -0,0 +1,72 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { fakeMeasureContext } from '../../__tests__/test-doubles' +import { RateLimiter } from '../rate-limiter' + +const ctx = fakeMeasureContext() + +describe('RateLimiter', () => { + it('allows requests up to the limit and then refuses', () => { + const now = 0 + const limiter = new RateLimiter(ctx, 3, 1000, () => now) + + expect(limiter.check('a')).toBe(2) + expect(limiter.check('a')).toBe(1) + expect(limiter.check('a')).toBe(0) + expect(limiter.check('a')).toBe(-1) + }) + + it('keeps separate buckets per key', () => { + const limiter = new RateLimiter(ctx, 1, 1000, () => 0) + expect(limiter.check('a')).toBe(0) + expect(limiter.check('b')).toBe(0) + expect(limiter.check('a')).toBe(-1) + }) + + it('lets the window slide', () => { + let now = 0 + const limiter = new RateLimiter(ctx, 2, 1000, () => now) + + limiter.check('a') + limiter.check('a') + expect(limiter.check('a')).toBe(-1) + + now = 1001 + expect(limiter.check('a')).toBe(1) + }) + + it('reports how long to wait before retrying', () => { + let now = 0 + const limiter = new RateLimiter(ctx, 1, 1000, () => now) + limiter.check('a') + expect(limiter.retryAfterMs('a')).toBe(1000) + + now = 400 + expect(limiter.retryAfterMs('a')).toBe(600) + }) + + it('reclaims buckets on sweep', () => { + let now = 0 + const limiter = new RateLimiter(ctx, 5, 100, () => now) + limiter.check('a') + limiter.check('b') + expect(limiter.size).toBe(2) + + now = 200 + limiter.sweep() + expect(limiter.size).toBe(0) + }) +}) diff --git a/pods/mcp/src/middleware/index.ts b/pods/mcp/src/middleware/index.ts new file mode 100644 index 0000000000..6439bc6bb5 --- /dev/null +++ b/pods/mcp/src/middleware/index.ts @@ -0,0 +1,114 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Analytics } from '@hcengineering/analytics' +import { type MeasureContext, metricsAggregate } from '@hcengineering/core' +import { getCPUInfo, getMemoryInfo } from '@hcengineering/server-core' +import { type ErrorRequestHandler, type NextFunction, type Request, type RequestHandler, type Response } from 'express' + +import { type Config } from '../config' +import { HttpError } from '../error' +import { type RateLimiter } from './rate-limiter' + +export const KEEP_ALIVE_TIMEOUT = 5 +export const KEEP_ALIVE_MAX = 1000 + +export const keepAlive = (options: { timeout: number, max: number }): RequestHandler => { + const { timeout, max } = options + return (req: Request, res: Response, next: NextFunction) => { + res.setHeader('Connection', 'keep-alive') + res.setHeader('Keep-Alive', `timeout=${timeout}, max=${max}`) + next() + } +} + +/** + * Logs one line per completed request. + * + * Built on `res.on('finish')` rather than copying the morgan + LogStream + * idiom the other pods use: that pattern needs a writable stream shim, and + * nothing here streams. The output format is the same. + */ +export const requestLogger = (ctx: MeasureContext): RequestHandler => { + const requests = ctx.newChild('requests', {}, { span: false }) + return (req: Request, res: Response, next: NextFunction) => { + const startedAt = Date.now() + res.on('finish', () => { + requests.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - startedAt}ms`) + }) + next() + } +} + +/** + * Per-caller rate limiting. + * + * The key prefers the client address, which is the only thing available before + * authentication runs. The transactor's own limiter is coupled to a live + * Session, which a stateless MCP endpoint never has, so this is the only limit + * in front of the tools. + */ +export const rateLimit = (limiter: RateLimiter, resolveKey: (req: Request) => string): RequestHandler => { + return (req: Request, res: Response, next: NextFunction) => { + const key = resolveKey(req) + const remaining = limiter.check(key) + if (remaining < 0) { + const retryAfter = Math.ceil(limiter.retryAfterMs(key) / 1000) + res.setHeader('Retry-After', `${retryAfter}`) + res.status(429).json({ error: 'Rate limit exceeded', retryAfterSeconds: retryAfter }) + return + } + res.setHeader('X-RateLimit-Remaining', `${remaining}`) + next() + } +} + +export const statistics = (ctx: MeasureContext, config: Config): RequestHandler => { + return (req: Request, res: Response) => { + if (!config.EnableStats) { + res.status(404).json({ message: 'Not Found' }) + return + } + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'public, no-store, no-cache, must-revalidate, max-age=0') + res.status(200).json({ + metrics: metricsAggregate((ctx as unknown as { metrics: never }).metrics), + statistics: { cpu: getCPUInfo(), memory: getMemoryInfo() } + }) + } +} + +export const errorHandler = (ctx: MeasureContext): ErrorRequestHandler => { + return (err: any, req: Request, res: Response, _next: NextFunction) => { + if (res.headersSent) return + + if (err instanceof HttpError) { + ctx.warn('mcp http error', { status: err.status, message: err.message, path: req.path }) + res.status(err.status).json({ error: err.message }) + return + } + + // Express' JSON body parser raises this for malformed payloads. It is the + // client's mistake, not ours, and the raw body is not useful to echo back. + if (err?.type === 'entity.parse.failed' || err instanceof SyntaxError) { + res.status(400).json({ error: 'Request body is not valid JSON' }) + return + } + + ctx.error('mcp unhandled error', { error: err?.message, path: req.path }) + Analytics.handleError(err) + res.status(500).json({ error: 'Internal Server Error' }) + } +} diff --git a/pods/mcp/src/middleware/rate-limiter.ts b/pods/mcp/src/middleware/rate-limiter.ts new file mode 100644 index 0000000000..3136531798 --- /dev/null +++ b/pods/mcp/src/middleware/rate-limiter.ts @@ -0,0 +1,78 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type MeasureContext } from '@hcengineering/core' + +/** + * Sliding-window rate limiter. + * + * Standalone pods get no rate limiting from the platform — the transactor's + * limiter is coupled to having a live Session — so an MCP endpoint that accepts + * internet traffic needs its own. Buckets are keyed by the authenticated account + * where known and by client address otherwise, so one noisy agent cannot starve + * the rest. + */ +export class RateLimiter { + private readonly ctx: MeasureContext + private readonly max: number + private readonly windowMs: number + private readonly now: () => number + private readonly buckets = new Map() + + constructor (ctx: MeasureContext, max: number, windowMs: number, now: () => number = Date.now) { + this.ctx = ctx + this.max = max + this.windowMs = windowMs + this.now = now + } + + /** @returns the number of requests left in the window, or -1 when limited. */ + check (key: string): number { + const now = this.now() + const cutoff = now - this.windowMs + const hits = (this.buckets.get(key) ?? []).filter((at) => at > cutoff) + + if (hits.length >= this.max) { + this.buckets.set(key, hits) + this.ctx.warn('mcp rate limit exceeded', { key, limit: this.max, windowMs: this.windowMs }) + return -1 + } + + hits.push(now) + this.buckets.set(key, hits) + return this.max - hits.length + } + + /** Milliseconds until the oldest hit leaves the window. */ + retryAfterMs (key: string): number { + const hits = this.buckets.get(key) ?? [] + const oldest = hits[0] + if (oldest === undefined) return 0 + return Math.max(0, this.windowMs - (this.now() - oldest)) + } + + sweep (): void { + const cutoff = this.now() - this.windowMs + for (const [key, hits] of [...this.buckets]) { + const live = hits.filter((at) => at > cutoff) + if (live.length === 0) this.buckets.delete(key) + else this.buckets.set(key, live) + } + } + + get size (): number { + return this.buckets.size + } +} diff --git a/pods/mcp/src/platform/markup-reader.ts b/pods/mcp/src/platform/markup-reader.ts new file mode 100644 index 0000000000..0daa4c1b68 --- /dev/null +++ b/pods/mcp/src/platform/markup-reader.ts @@ -0,0 +1,30 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/** + * Resolves a markup blob reference to its text content. + * + * An interface rather than a concrete class so tool handlers depend on the + * capability, not on HTTP. + */ +export interface MarkupReader { + read: (ref: string) => Promise +} + +/** Reads a bounded number of characters, appending a marker when it truncates. */ +export function truncate (value: string, maxChars: number): string { + if (value.length <= maxChars) return value + return `${value.slice(0, maxChars)}\n… [truncated, ${value.length} characters total]` +} diff --git a/pods/mcp/src/platform/workspace-client-provider.ts b/pods/mcp/src/platform/workspace-client-provider.ts new file mode 100644 index 0000000000..60a3f04c1d --- /dev/null +++ b/pods/mcp/src/platform/workspace-client-provider.ts @@ -0,0 +1,182 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { createRestTxOperations } from '@hcengineering/api-client' +import { type AccountUuid, type MeasureContext, type TxOperations, type WorkspaceUuid } from '@hcengineering/core' + +import { type SessionIdentity } from '../auth/authenticator' +import { type MarkupReader } from './markup-reader' + +/** Everything a tool needs to talk to one workspace on behalf of one user. */ +export interface WorkspaceSession { + client: TxOperations + identity: SessionIdentity + markup: MarkupReader +} + +export interface WorkspaceClientProvider { + get: (identity: SessionIdentity) => Promise + close: () => Promise +} + +/** Injected so tests can supply a fake TxOperations without a live platform. */ +export type ClientFactory = (identity: SessionIdentity) => Promise + +export interface WorkspaceClientProviderOptions { + ctx: MeasureContext + createClient?: ClientFactory + createMarkupReader?: (identity: SessionIdentity) => MarkupReader + /** Idle time after which a cached client is closed, in milliseconds. */ + idleTtlMs?: number + /** How often idle entries are swept, in milliseconds. */ + sweepIntervalMs?: number + now?: () => number +} + +interface CacheEntry { + session: WorkspaceSession + lastUsed: number +} + +const DEFAULT_IDLE_TTL_MS = 10 * 60_000 +const DEFAULT_SWEEP_INTERVAL_MS = 60_000 + +const keyOf = (account: AccountUuid, workspace: WorkspaceUuid): string => `${account}:${workspace}` + +/** + * Caches one `TxOperations` per (account, workspace). + * + * Building a client is not cheap: `createRestTxOperations` fetches the account + * and the full workspace model over HTTP. MCP sessions are long lived and many + * tools fire back to back, so a per-request client would multiply that cost by + * an order of magnitude. + * + * Caching per *account* (not just per workspace) matters for correctness, not + * just speed: a client is bound to a social id, and every write is attributed + * to that social id. Sharing one client between users would attribute their + * edits to whoever happened to populate the cache first. + */ +export class CachingWorkspaceClientProvider implements WorkspaceClientProvider { + private readonly ctx: MeasureContext + private readonly createClient: ClientFactory + private readonly createMarkupReader: (identity: SessionIdentity) => MarkupReader + private readonly idleTtlMs: number + private readonly now: () => number + private readonly cache = new Map() + private readonly inFlight = new Map>() + private readonly sweeper: ReturnType + + constructor (options: WorkspaceClientProviderOptions) { + this.ctx = options.ctx + this.createClient = options.createClient ?? defaultClientFactory + this.createMarkupReader = options.createMarkupReader ?? defaultMarkupReaderFactory + this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS + this.now = options.now ?? Date.now + + this.sweeper = setInterval(() => { + void this.sweep() + }, options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS) + // Never hold the event loop open just for the sweeper. + this.sweeper.unref?.() + } + + async get (identity: SessionIdentity): Promise { + const key = keyOf(identity.account, identity.workspace) + + const cached = this.cache.get(key) + if (cached !== undefined) { + cached.lastUsed = this.now() + return cached.session + } + + // Collapse concurrent first-hits for the same identity into one build, + // otherwise a burst of parallel tool calls each loads the whole model. + const existing = this.inFlight.get(key) + if (existing !== undefined) return await existing + + const creation = (async (): Promise => { + const client = await this.createClient(identity) + const session: WorkspaceSession = { + client, + identity, + markup: this.createMarkupReader(identity) + } + this.cache.set(key, { session, lastUsed: this.now() }) + return session + })() + + this.inFlight.set(key, creation) + try { + return await creation + } finally { + this.inFlight.delete(key) + } + } + + private async sweep (): Promise { + const cutoff = this.now() - this.idleTtlMs + for (const [key, entry] of [...this.cache]) { + if (entry.lastUsed > cutoff) continue + this.cache.delete(key) + try { + await entry.session.client.close() + } catch (err) { + this.ctx.warn('mcp workspace client close failed', { key, error: (err as Error)?.message }) + } + } + } + + async close (): Promise { + clearInterval(this.sweeper) + const entries = [...this.cache.values()] + this.cache.clear() + await Promise.all( + entries.map(async (entry) => { + try { + await entry.session.client.close() + } catch { + // Shutdown is best effort; a stuck client must not block the exit. + } + }) + ) + } + + /** Test and diagnostic helper. */ + get size (): number { + return this.cache.size + } +} + +const defaultClientFactory: ClientFactory = async (identity) => + await createRestTxOperations(identity.transactorUrl, identity.workspace, identity.workspaceToken, true) + +const defaultMarkupReaderFactory = (identity: SessionIdentity): MarkupReader => ({ + read: async (ref: string) => await fetchMarkup(identity.transactorUrl, identity.workspaceToken, ref) +}) + +/** + * Reads a markup blob through the transactor's blob endpoint. + * + * Issue and document bodies are `MarkupBlobRef`s pointing at the blob store, + * not inline text, so a tool that wants to show a description has to resolve it. + * Failures resolve to an empty string: a missing description blob is a data + * consistency issue, not a reason to fail the whole tool call. + */ +async function fetchMarkup (transactorUrl: string, token: string, ref: string): Promise { + const url = `${transactorUrl.replace(/\/$/, '')}/api/v1/blob?name=${encodeURIComponent(ref)}` + const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) + if (!response.ok) return '' + return await response.text() +} diff --git a/pods/mcp/src/server.ts b/pods/mcp/src/server.ts new file mode 100644 index 0000000000..829521e35d --- /dev/null +++ b/pods/mcp/src/server.ts @@ -0,0 +1,172 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type MeasureContext } from '@hcengineering/core' +import cors from 'cors' +import express, { type Express, type Request, type RequestHandler, type Response } from 'express' +import { type Server } from 'node:http' + +import { type Authenticator } from './auth/authenticator' +import { type Config } from './config' +import { McpDispatcher } from './mcp/dispatcher' +import { SessionStore } from './mcp/session' +import { SESSION_HEADER, StreamableHttpTransport } from './mcp/streamable-http' +import { type ToolRegistry } from './mcp/tool' +import { + errorHandler, + keepAlive, + KEEP_ALIVE_MAX, + KEEP_ALIVE_TIMEOUT, + rateLimit, + requestLogger, + statistics +} from './middleware' +import { type RateLimiter } from './middleware/rate-limiter' +import { type WorkspaceClientProvider } from './platform/workspace-client-provider' +import { MCP_INSTRUCTIONS } from './tools/register' + +export const MCP_ENDPOINT = '/mcp' + +/** Replaced at bundle time by esbuild. */ +const VERSION = process.env.VERSION ?? '0.7.0' + +export interface ServerDependencies { + ctx: MeasureContext + config: Config + registry: ToolRegistry + clients: WorkspaceClientProvider + authenticator: Authenticator + limiter: RateLimiter + sessions?: SessionStore +} + +export interface McpServer { + app: Express + sessions: SessionStore + transport: StreamableHttpTransport +} + +/** + * Adapts an async handler to Express. + * + * Express 4 ignores the returned promise, so an unhandled rejection inside a + * handler would otherwise be invisible. This routes it to `next`, which is what + * the error handler middleware is watching for. + */ +const asyncHandler = + (fn: (req: Request, res: Response) => Promise): RequestHandler => + (req, res, next) => { + fn(req, res).catch(next) + } + +/** + * Assembles the HTTP layer. + * + * The dispatcher and transport are constructed here, from injected + * collaborators, rather than passed in pre-built. That keeps the wiring visible + * in one place and lets tests swap the authenticator or the client provider + * without having to construct a transport by hand. + */ +export function createServer (deps: ServerDependencies): McpServer { + const { ctx, config, registry, clients, authenticator, limiter } = deps + + const sessions = deps.sessions ?? new SessionStore({ ctx, idleTtlMs: config.SessionIdleTtlMs }) + + const dispatcher = new McpDispatcher({ + ctx, + registry, + serverName: 'huly-mcp', + serverVersion: VERSION, + instructions: MCP_INSTRUCTIONS, + resolveSession: async (session) => await clients.get(session.identity) + }) + + const transport = new StreamableHttpTransport({ ctx, store: sessions, dispatcher, authenticator }) + + const app = express() + app.disable('x-powered-by') + + app.use( + cors({ + maxAge: 86400, + // A browser-based MCP client must be able to read the session id, or + // every request after initialize would be rejected as session-less. + exposedHeaders: [SESSION_HEADER, 'WWW-Authenticate', 'Retry-After', 'X-RateLimit-Remaining'], + allowedHeaders: ['Content-Type', 'Authorization', SESSION_HEADER, 'Mcp-Protocol-Version', 'Last-Event-ID'] + }) + ) + + // Bodies are JSON-RPC envelopes; a megabyte is generous and still bounded. + app.use(express.json({ limit: config.MaxBodyBytes })) + app.use(keepAlive({ timeout: KEEP_ALIVE_TIMEOUT, max: KEEP_ALIVE_MAX })) + app.use(requestLogger(ctx)) + + // Limiting sits in front of the MCP routes only, so /health keeps working + // when a client manages to exhaust the window. + const rateKey = (req: Request): string => req.ip ?? req.socket.remoteAddress ?? 'unknown' + app.use(MCP_ENDPOINT, rateLimit(limiter, rateKey)) + + app.post(MCP_ENDPOINT, asyncHandler(transport.handlePost)) + app.get(MCP_ENDPOINT, asyncHandler(transport.handleGet)) + app.delete(MCP_ENDPOINT, asyncHandler(transport.handleDelete)) + + app.get('/api/v1/health', (_req: Request, res: Response) => { + res.status(200).json({ + status: 'ok', + version: VERSION, + authMode: config.AuthMode, + readOnly: config.ReadOnly, + tools: registry.size, + sessions: sessions.size + }) + }) + + app.get('/api/v1/statistics', statistics(ctx, config)) + + app.get('/', (_req: Request, res: Response) => { + res.type('text/plain').send( + [ + 'Huly MCP Server', + '', + `MCP endpoint: POST ${MCP_ENDPOINT}`, + 'Health: GET /api/v1/health', + `Auth mode: ${config.AuthMode}${config.ReadOnly ? ' (read-only)' : ''}`, + `Tools: ${registry.size}`, + '' + ].join('\n') + ) + }) + + app.use((_req: Request, res: Response) => { + res.status(404).json({ error: 'Not Found' }) + }) + + app.use(errorHandler(ctx)) + + return { app, sessions, transport } +} + +export function listen (app: Express, port: number, host: string): Server { + const server = app.listen(port, host, () => { + console.log(`Huly MCP server listening on ${host}:${port}`) + }) + // SSE responses are long-lived by design, so the socket timeouts have to sit + // above the keepalive interval or an idle stream gets reaped mid-conversation. + server.keepAliveTimeout = (KEEP_ALIVE_TIMEOUT + 30) * 1000 + server.headersTimeout = (KEEP_ALIVE_TIMEOUT + 35) * 1000 + // SSE streams are long-lived; capping requests per socket would throttle them. + server.maxRequestsPerSocket = 0 + return server +} diff --git a/pods/mcp/src/tools/document-tools.ts b/pods/mcp/src/tools/document-tools.ts new file mode 100644 index 0000000000..205ffc291c --- /dev/null +++ b/pods/mcp/src/tools/document-tools.ts @@ -0,0 +1,140 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { SortingOrder } from '@hcengineering/core' +import doc from '@hcengineering/document' + +import { truncate } from '../platform/markup-reader' +import { textResult } from '../mcp/protocol' +import { numberProp, objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool } from '../mcp/tool' +import { clampLimit, toIso } from './shared' + +/** Bodies can be very large; keep responses inside a model's context window. */ +const BODY_CHAR_LIMIT = 20_000 + +interface DocumentRow { + _id: string + title: string + space: string + modifiedOn: number + createdOn: number + size: number + content?: string | null +} + +export const listDocumentsTool: HulyTool = { + name: 'huly_list_documents', + title: 'List documents', + description: + 'List documents in a teamspace or drive, most recently modified first. ' + + 'Use the id with huly_get_document to read the body. ' + + 'Get space ids from huly_list_spaces.', + readOnly: true, + inputSchema: objectSchema({ + spaceId: stringProp('Teamspace, project or drive id to list documents from.'), + search: stringProp('Case-insensitive substring match on the document title.'), + limit: { type: 'integer', description: 'Maximum documents to return (1-200, default 50).', default: 50 } + }), + handler: async (ctx, args) => { + const query: Record = {} + if (args.spaceId !== undefined) query.space = args.spaceId + if (args.search !== undefined) query.title = { $regex: escapeRegExp(String(args.search)), $options: 'i' } + + const documents = (await ctx.client.findAll(doc.class.Document, query as never, { + limit: clampLimit(args.limit), + sort: { modifiedOn: SortingOrder.Descending } + })) as unknown as DocumentRow[] + + const rows = documents.map((document) => ({ + id: document._id, + title: document.title, + spaceId: document.space, + size: document.size, + modifiedOn: toIso(document.modifiedOn) + })) + + if (rows.length === 0) { + return textResult('No documents matched.', { documents: [] }) + } + + return textResult(JSON.stringify({ documents: rows }, null, 2), { documents: rows }) + } +} + +export const getDocumentTool: HulyTool = { + name: 'huly_get_document', + title: 'Read document', + description: + 'Read one document and return its text content. Long documents are truncated, and the tool ' + + 'result reports whether truncation happened.', + readOnly: true, + inputSchema: objectSchema( + { + documentId: stringProp('Document id from huly_list_documents.'), + maxChars: numberProp('Maximum characters of body to return (default 20000).', { + minimum: 100, + maximum: 100_000, + default: BODY_CHAR_LIMIT + }) + }, + ['documentId'] + ), + handler: async (ctx, args) => { + const documentId = args.documentId as string + + const document = (await ctx.client.findOne(doc.class.Document, { _id: documentId } as never)) as + | unknown as DocumentRow + | undefined + + if (document === undefined) { + return textResult( + `No document with id ${documentId} is visible to you. It may have been deleted or live in a ` + + 'space you are not a member of.', + { found: false } + ) + } + + const maxChars = typeof args.maxChars === 'number' ? args.maxChars : BODY_CHAR_LIMIT + const body = await ctx.markup.read(document.content as string) + const truncatedBody = truncate(body.trim(), maxChars) + + return textResult( + JSON.stringify( + { + id: document._id, + title: document.title, + spaceId: document.space, + modifiedOn: toIso(document.modifiedOn), + content: truncatedBody + }, + null, + 2 + ), + { + found: true, + documentId: document._id, + title: document.title, + truncated: body.length > maxChars + } + ) + } +} + +function escapeRegExp (value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export const documentTools: HulyTool[] = [listDocumentsTool, getDocumentTool] diff --git a/pods/mcp/src/tools/issue-tools.ts b/pods/mcp/src/tools/issue-tools.ts new file mode 100644 index 0000000000..95402036d3 --- /dev/null +++ b/pods/mcp/src/tools/issue-tools.ts @@ -0,0 +1,532 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import chunter, { type ChatMessage } from '@hcengineering/chunter' +import core, { generateId, SortingOrder } from '@hcengineering/core' +import tracker, { IssuePriority, type Issue, type Milestone } from '@hcengineering/tracker' + +import { textResult } from '../mcp/protocol' +import { booleanProp, objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool, type ToolContext } from '../mcp/tool' +import { clampLimit, personNames, projectNames, statusNames, toIso, uniqueIds } from './shared' + +/** The slice of an Issue the tools read. */ +interface IssueRow { + _id: string + number: number + title: string + status: string + priority: number + assignee: string | null + space: string + labels: number + rank: string + createdOn: number + modifiedOn: number + startDate: number | null + dueDate: number | null + milestone: string | null + description?: string | null + estimation: number +} + +const PRIORITY_NAMES = Object.keys(IssuePriority).filter((key) => Number.isNaN(Number(key))) + +const priorityName = (value: number): string => PRIORITY_NAMES[value] ?? `Unknown(${value})` + +const priorityIndex = (name: string | undefined): number => { + if (name === undefined) return IssuePriority.NoPriority + const index = PRIORITY_NAMES.indexOf(name) + return index < 0 ? IssuePriority.NoPriority : index +} + +const uniqueStatuses = (issues: IssueRow[]): string[] => uniqueIds(issues.map((issue) => issue.status)) + +/** + * Turns raw issues into the shape an agent can act on: human names instead of + * ids, ISO dates instead of epoch millis, and an `identifier-number` key. + */ +async function formatIssues (ctx: ToolContext, issues: IssueRow[]): Promise>> { + const [people, statuses, projects] = await Promise.all([ + personNames(ctx.client, issues.map((issue) => issue.assignee)), + statusNames(ctx.client, issues.map((issue) => issue.status)), + projectNames(ctx.client, issues.map((issue) => issue.space)) + ]) + + return issues.map((issue) => { + const project = projects.get(issue.space) + const status = statuses.get(issue.status) + return { + id: issue._id, + key: project?.identifier != null ? `${project.identifier}-${issue.number}` : `${issue.number}`, + title: issue.title, + status: status?.name ?? 'Unknown', + statusCategory: status?.category ?? null, + priority: priorityName(issue.priority), + assignee: issue.assignee === null ? null : (people.get(issue.assignee) ?? 'Unknown'), + projectId: issue.space, + project: project?.name ?? null, + labels: issue.labels, + startDate: toIso(issue.startDate), + dueDate: toIso(issue.dueDate), + estimation: issue.estimation, + modifiedOn: toIso(issue.modifiedOn) + } + }) +} + +export const listIssuesTool: HulyTool = { + name: 'huly_list_issues', + title: 'List issues', + description: + 'List issues, most recently modified first. Filter by project, status name, assignee or free text. ' + + 'Issue statuses are matched by name; call huly_list_issue_statuses to see the valid values. ' + + 'Returns at most 200 issues per call; narrow the filters for large projects.', + readOnly: true, + inputSchema: objectSchema({ + projectId: stringProp('Restrict to one project id.'), + status: stringProp('Restrict to one status, matched case-insensitively by name (e.g. "In Progress").'), + assignee: stringProp('Restrict to issues assigned to this person id. Use huly_find_people to look one up.'), + search: stringProp('Case-insensitive substring match against the issue title.'), + includeDone: booleanProp('Include issues in a "done" category. Defaults to true.'), + limit: { type: 'integer', description: 'Maximum issues to return (1-200, default 50).', default: 50 } + }), + handler: async (ctx, args) => { + const limit = clampLimit(args.limit) + const query: Record = {} + + if (args.projectId !== undefined) query.space = args.projectId + if (args.assignee !== undefined) query.assignee = args.assignee + if (args.search !== undefined) { + // Anchored, escaped substring match: an unescaped user string would be + // interpreted as a regular expression by the storage layer. + query.title = { $regex: `^${escapeRegExp(String(args.search))}`, $options: 'i' } + } + + let issues = (await ctx.client.findAll(tracker.class.Issue, query as never, { + limit, + sort: { modifiedOn: SortingOrder.Descending } + })) as unknown as IssueRow[] + + // Status filtering happens in JS on purpose: status is a ref to a document, + // so filtering by name would otherwise need a join the storage layer does + // not do. The page limit was already applied above. + if (args.status !== undefined || args.includeDone === false) { + const statuses = await statusNames(ctx.client, uniqueStatuses(issues)) + + if (args.status !== undefined) { + const wanted = String(args.status).toLowerCase() + issues = issues.filter((issue) => (statuses.get(issue.status)?.name ?? '').toLowerCase() === wanted) + } + + if (args.includeDone === false) { + issues = issues.filter((issue) => { + const category = statuses.get(issue.status)?.category + // A status with no category cannot be known to be done, so it stays. + return category == null || category.toLowerCase() !== 'done' + }) + } + } + + if (issues.length === 0) { + return textResult('No issues matched. Try widening the filters or call huly_list_projects.', { + issues: [] + }) + } + + const formatted = await formatIssues(ctx, issues) + return textResult(JSON.stringify({ issues: formatted }, null, 2), { issues: formatted }) + } +} + +export const getIssueTool: HulyTool = { + name: 'huly_get_issue', + title: 'Get issue', + description: + 'Get one issue in full: description text, subtasks and comments. ' + + 'Pass the issue id returned by huly_list_issues or huly_search.', + readOnly: true, + inputSchema: objectSchema({ issueId: stringProp('Issue id.') }, ['issueId']), + handler: async (ctx, args) => { + const issueId = args.issueId as string + + const issue = (await ctx.client.findOne(tracker.class.Issue, { _id: issueId } as never)) as + | unknown as IssueRow + | undefined + + if (issue === undefined) { + return textResult( + `No issue with id ${issueId} is visible to you. It may have been deleted or belong to another project.`, + { found: false } + ) + } + + const subtaskQuery: Record = { space: issue.space, parent: issue._id } + const commentQuery: Record = { attachedTo: issueId, collection: 'comments' } + + const [subtasks, comments, description] = await Promise.all([ + ctx.client.findAll(tracker.class.Issue, subtaskQuery as never, { + limit: 100, + sort: { rank: SortingOrder.Ascending } + }) as Promise, + ctx.client.findAll(chunter.class.ChatMessage, commentQuery as never, { + limit: 200, + sort: { createdOn: SortingOrder.Ascending } + }) as Promise, + ctx.markup.read(issue.description as string) + ]) + + const subtaskRows = await formatIssues(ctx, subtasks as IssueRow[]) + const commentRows = await formatComments(ctx, comments as ChatMessage[]) + const [formatted] = await formatIssues(ctx, [issue]) + + return textResult( + JSON.stringify( + { + ...formatted, + description: (description ?? '').trim(), + subtasks: subtaskRows, + comments: commentRows + }, + null, + 2 + ), + { found: true, issueId: issue._id, commentCount: commentRows.length, subtaskCount: subtaskRows.length } + ) + } +} + +async function formatComments (ctx: ToolContext, comments: ChatMessage[]): Promise>> { + if (comments.length === 0) return [] + const people = await personNames(ctx.client, comments.map((comment) => comment.createdBy as never)) + return comments.map((comment) => ({ + id: comment._id, + author: people.get(comment.createdBy as never) ?? 'Unknown', + createdOn: toIso(comment.createdOn), + text: (comment.message ?? '').trim() + })) +} + +export const listIssueStatusesTool: HulyTool = { + name: 'huly_list_issue_statuses', + title: 'List issue statuses', + description: + 'List every issue status in this workspace with its id, display name and category. ' + + 'Status ids are what huly_update_issue expects; names are what huly_list_issues matches on.', + readOnly: true, + inputSchema: objectSchema({}), + handler: async (ctx) => { + const query: Record = { ofAttribute: tracker.attribute.IssueStatus } + const statuses = (await ctx.client.findAll(tracker.class.IssueStatus, query as never, { + limit: 200 + })) as unknown as Array<{ _id: string, name: string }> + + const resolved = await statusNames(ctx.client, statuses.map((status) => status._id)) + + const rows = statuses.map((status) => ({ + id: status._id, + name: resolved.get(status._id)?.name ?? status.name, + category: resolved.get(status._id)?.category ?? null + })) + + return textResult(JSON.stringify({ statuses: rows }, null, 2), { statuses: rows }) + } +} + +export const createIssueTool: HulyTool = { + name: 'huly_create_issue', + title: 'Create issue', + description: + 'Create a new issue in a project. The description is plain text or Markdown. ' + + 'The issue starts in the project default status unless statusId is given. ' + + 'Due dates are ISO-8601. Call huly_get_project first to learn the default status.', + readOnly: false, + inputSchema: objectSchema( + { + projectId: stringProp('Project id from huly_list_projects.'), + title: stringProp('Short issue title.', { minLength: 1, maxLength: 500 }), + description: stringProp('Longer description. Plain text or Markdown.', { maxLength: 200_000 }), + priority: { + type: 'string', + description: 'Issue priority.', + enum: ['NoPriority', 'Urgent', 'High', 'Medium', 'Low'] + }, + statusId: stringProp('Status id from huly_list_issue_statuses. Defaults to the project default.'), + assignee: stringProp('Person id to assign. Use huly_find_people to look one up.'), + dueDate: stringProp('ISO-8601 due date, e.g. 2026-12-31 or 2026-12-31T17:00:00Z.'), + startDate: stringProp('ISO-8601 start date.'), + milestone: stringProp('Milestone id to attach the issue to.') + }, + ['projectId', 'title'] + ), + handler: async (ctx, args) => { + const projectId = args.projectId as string + + const project = (await ctx.client.findOne(tracker.class.Project, { _id: projectId } as never)) as + | unknown as ProjectDefaults + | undefined + + if (project === undefined) { + return textResult( + `No project with id ${projectId} is visible to you, so the issue was not created.`, + { created: false } + ) + } + + const statusId = (args.statusId as string | undefined) ?? project.defaultIssueStatus + if (statusId === undefined) { + return textResult( + 'The project has no default issue status configured, so the issue was not created. ' + + 'Pass an explicit statusId, or ask an administrator to set a default status on the project.', + { created: false } + ) + } + + // Numbers are assigned per project. Huly's numbering middleware lives + // outside this repository, so the next number is derived here; a concurrent + // create could claim the same value. + const highestQuery: Record = { space: projectId } + const highest = (await ctx.client.findAll(tracker.class.Issue, highestQuery as never, { + limit: 1, + sort: { number: SortingOrder.Descending }, + projection: { number: 1 } + })) as unknown as Array<{ number: number }> + const number = (highest[0]?.number ?? 0) + 1 + + const issueId = generateId() + const attributes: Record = { + number, + title: args.title, + status: statusId, + priority: priorityIndex(args.priority as string | undefined), + assignee: (args.assignee as string | undefined) ?? null, + space: projectId, + startDate: toTimestamp(args.startDate as string | undefined), + dueDate: toTimestamp(args.dueDate as string | undefined), + estimation: 0, + remainingTime: 0, + reportedTime: 0, + milestone: (args.milestone as string | undefined) ?? null + } + + await ctx.client.createDoc(tracker.class.Issue, projectId as never, attributes as never, issueId) + + return textResult( + JSON.stringify({ id: issueId, key: `${project.identifier ?? '?'}-${number}`, title: args.title }, null, 2), + { created: true, issueId } + ) + } +} + +interface ProjectDefaults { + identifier?: string + defaultIssueStatus?: string +} + +export const updateIssueTool: HulyTool = { + name: 'huly_update_issue', + title: 'Update issue', + description: + 'Change fields on an existing issue. Only the fields you pass are changed; everything else is left alone. ' + + 'Use statusId from huly_list_issue_statuses. Passing null for dueDate, startDate, assignee or ' + + 'milestone clears that field.', + readOnly: false, + inputSchema: objectSchema( + { + issueId: stringProp('Issue id from huly_list_issues.'), + title: stringProp('New title.', { maxLength: 500 }), + statusId: stringProp('New status id from huly_list_issue_statuses.'), + priority: { type: 'string', description: 'New priority.', enum: ['NoPriority', 'Urgent', 'High', 'Medium', 'Low'] }, + assignee: stringProp('New assignee person id, or null to unassign.'), + dueDate: stringProp('New ISO-8601 due date, or null to clear it.'), + startDate: stringProp('New ISO-8601 start date, or null to clear it.'), + milestone: stringProp('New milestone id, or null to detach.') + }, + ['issueId'] + ), + handler: async (ctx, args) => { + const issueId = args.issueId as string + + const issue = (await ctx.client.findOne(tracker.class.Issue, { _id: issueId } as never)) as + | unknown as IssueRow + | undefined + + if (issue === undefined) { + return textResult(`No issue with id ${issueId} is visible to you, so nothing was changed.`, { + updated: false + }) + } + + const operations: Record = {} + + if (args.title !== undefined) operations.title = args.title + if (args.statusId !== undefined) operations.status = args.statusId + if (args.priority !== undefined) operations.priority = priorityIndex(args.priority as string) + if (args.assignee !== undefined) operations.assignee = args.assignee + if (args.dueDate !== undefined) operations.dueDate = toTimestamp(args.dueDate as string) + if (args.startDate !== undefined) operations.startDate = toTimestamp(args.startDate as string) + if (args.milestone !== undefined) operations.milestone = args.milestone + + const changed = Object.keys(operations) + if (changed.length === 0) { + return textResult( + 'No fields to update. Pass at least one of title, statusId, priority, assignee, dueDate or milestone.', + { updated: false } + ) + } + + await ctx.client.updateDoc(tracker.class.Issue, issue.space as never, issueId as never, operations as never) + + return textResult(`Updated issue ${issueId}: ${changed.sort().join(', ')}.`, { updated: true, changed }) + } +} + +export const addCommentTool: HulyTool = { + name: 'huly_add_issue_comment', + title: 'Comment on an issue', + description: 'Append a comment to an issue. Comments are visible to everyone with access to the issue.', + readOnly: false, + inputSchema: objectSchema( + { + issueId: stringProp('Issue id from huly_list_issues.'), + text: stringProp('Comment body. Plain text or Markdown.', { minLength: 1, maxLength: 100_000 }) + }, + ['issueId', 'text'] + ), + handler: async (ctx, args) => { + const issueId = args.issueId as string + const issue = (await ctx.client.findOne(tracker.class.Issue, { _id: issueId } as never)) as + | unknown as IssueRow + | undefined + + if (issue === undefined) { + return textResult(`No issue with id ${issueId} is visible to you, so no comment was added.`, { + created: false + }) + } + + const messageId = generateId() + const attributes: Record = { + attachedTo: issueId, + collection: 'comments', + message: args.text + } + await ctx.client.createDoc(chunter.class.ChatMessage, core.space.Space, attributes as never, messageId) + + return textResult(`Commented on issue ${issueId}.`, { created: true, messageId }) + } +} + +export const createMilestoneTool: HulyTool = { + name: 'huly_create_milestone', + title: 'Create milestone', + description: + 'Create a milestone in a project and optionally attach existing issues to it. ' + + 'Use huly_list_issues to find issue ids first. Issues that could not be attached are ' + + 'reported back under skippedIssues rather than silently dropped.', + readOnly: false, + inputSchema: objectSchema( + { + projectId: stringProp('Project id from huly_list_projects.'), + name: stringProp('Milestone name.', { minLength: 1, maxLength: 200 }), + description: stringProp('Milestone description.', { maxLength: 20_000 }), + dueDate: stringProp('ISO-8601 target date.'), + issueIds: { + type: 'array', + description: 'Issue ids to attach to the new milestone.', + items: { type: 'string' }, + maxItems: 200 + } + }, + ['projectId', 'name'] + ), + handler: async (ctx, args) => { + const projectId = args.projectId as string + const projectQuery: Record = { _id: projectId } + const project = await ctx.client.findOne(tracker.class.Project, projectQuery as never) + + if (project === undefined) { + return textResult(`No project with id ${projectId} is visible to you, so nothing was created.`, { + created: false + }) + } + + const milestoneId = generateId() + const attributes: Record = { + name: args.name, + description: (args.description as string | undefined) ?? '', + dueDate: toTimestamp(args.dueDate as string | undefined), + project: projectId, + done: [] + } + await ctx.client.createDoc(tracker.class.Milestone, projectId as never, attributes as never, milestoneId) + + const issueIds = (args.issueIds as string[] | undefined) ?? [] + const attached: string[] = [] + + for (const issueId of issueIds) { + const issue = (await ctx.client.findOne(tracker.class.Issue, { _id: issueId } as never)) as + | unknown as IssueRow + | undefined + // Silently skipping unknown ids would make a partial failure look like a + // complete one, so unattached ids are reported back to the caller. + if (issue === undefined || issue.space !== projectId) continue + await ctx.client.updateDoc( + tracker.class.Issue, + issue.space as never, + issue._id as never, + { milestone: milestoneId } as never + ) + attached.push(issue._id) + } + + return textResult( + JSON.stringify( + { + milestoneId, + name: args.name, + attachedIssues: attached, + skippedIssues: issueIds.filter((id) => !attached.includes(id)) + }, + null, + 2 + ), + { created: true, milestoneId, attached: attached.length } + ) + } +} + +function toTimestamp (value: string | null | undefined): number | null { + if (value === undefined || value === null || value === '') return null + const parsed = Date.parse(value) + if (Number.isNaN(parsed)) { + throw Error(`"${value}" is not a valid ISO-8601 date`) + } + return parsed +} + +function escapeRegExp (value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export const issueTools: HulyTool[] = [ + listIssuesTool, + getIssueTool, + listIssueStatusesTool, + createIssueTool, + updateIssueTool, + addCommentTool, + createMilestoneTool +] diff --git a/pods/mcp/src/tools/people-tools.ts b/pods/mcp/src/tools/people-tools.ts new file mode 100644 index 0000000000..5f6e075509 --- /dev/null +++ b/pods/mcp/src/tools/people-tools.ts @@ -0,0 +1,294 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import contact, { type Person } from '@hcengineering/contact' +import core, { generateId, SortingOrder } from '@hcengineering/core' +import drive, { type Drive } from '@hcengineering/drive' +import task from '@hcengineering/task' +import tracker from '@hcengineering/tracker' + +import { textResult } from '../mcp/protocol' +import { booleanProp, objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool } from '../mcp/tool' +import { clampLimit, personNames, statusNames, taskProjectNames, toIso } from './shared' + +interface TaskRow { + _id: string + number: number + title: string + status: string + assignee: string | null + kind: string + dueDate: number | null + modifiedOn: number + labels: number + isDone?: boolean + space: string +} + +interface MilestoneRow { + _id: string + name: string + dueDate?: number + done?: string[] +} + +export const listTasksTool: HulyTool = { + name: 'huly_list_tasks', + title: 'List tasks', + description: + 'List to-do tasks and subtasks, optionally scoped to one task project (board). ' + + 'Returns at most 200 per call.', + readOnly: true, + inputSchema: objectSchema({ + projectId: stringProp('Task project (board) id from huly_list_projects.'), + assignee: stringProp('Person id to filter by. Use huly_find_people.'), + includeDone: booleanProp('Include completed tasks. Defaults to true.'), + limit: { type: 'integer', description: 'Maximum tasks to return (1-200, default 50).', default: 50 } + }), + handler: async (ctx, args) => { + const query: Record = { space: core.space.Space } + if (args.projectId !== undefined) query.space = args.projectId + if (args.assignee !== undefined) query.assignee = args.assignee + + let rows = (await ctx.client.findAll(task.class.Task, query as never, { + limit: clampLimit(args.limit), + sort: { modifiedOn: SortingOrder.Descending } + })) as unknown as TaskRow[] + + if (args.includeDone === false) { + rows = rows.filter((row) => row.isDone !== true) + } + + if (rows.length === 0) { + return textResult('No tasks matched.', { tasks: [] }) + } + + const [people, statuses, projects] = await Promise.all([ + personNames(ctx.client, rows.map((row) => row.assignee)), + statusNames(ctx.client, rows.map((row) => row.status)), + taskProjectNames(ctx.client, rows.map((row) => row.space)) + ]) + + const tasks = rows.map((row) => ({ + id: row._id, + number: row.number, + title: row.title, + done: row.isDone === true, + status: statuses.get(row.status)?.name ?? 'Unknown', + assignee: row.assignee === null ? null : (people.get(row.assignee) ?? 'Unknown'), + projectId: row.space, + project: projects.get(row.space)?.name ?? null, + kind: row.kind, + dueDate: toIso(row.dueDate), + modifiedOn: toIso(row.modifiedOn) + })) + + return textResult(JSON.stringify({ tasks }, null, 2), { tasks }) + } +} + +export const findPeopleTool: HulyTool = { + name: 'huly_find_people', + title: 'Find people', + description: + 'Find people in this workspace by name or email. Use the returned id with the assignee fields ' + + 'of the issue and task tools.', + readOnly: true, + inputSchema: objectSchema({ + query: stringProp('Name or email to match, case-insensitively. Omit to list everyone.'), + limit: { type: 'integer', description: 'Maximum people to return (1-200, default 50).', default: 50 } + }), + handler: async (ctx, args) => { + const limit = clampLimit(args.limit) + + const persons = (await ctx.client.findAll( + contact.class.Person, + {}, + { limit, sort: { name: SortingOrder.Ascending } } + )) as unknown as Person[] + + const identityQuery: Record = { _id: { $in: persons.map((person) => person._id) } } + const identities = await ctx.client.findAll(contact.class.SocialIdentity, identityQuery as never, { + limit: persons.length + }) + + const byPerson = new Map() + for (const identity of identities as unknown as Array<{ _id: string, value: string }>) { + byPerson.set(identity._id, identity.value) + } + + const needle = (args.query as string | undefined)?.toLowerCase() + const rows = persons + .map((person) => ({ id: person._id, name: person.name, email: byPerson.get(person._id) ?? null })) + .filter( + (row) => + needle === undefined || + row.name.toLowerCase().includes(needle) || + (row.email ?? '').toLowerCase().includes(needle) + ) + + if (rows.length === 0) { + return textResult('No people matched.', { people: [] }) + } + + return textResult(JSON.stringify({ people: rows }, null, 2), { people: rows }) + } +} + +export const createPersonTool: HulyTool = { + name: 'huly_create_person', + title: 'Create person', + description: + 'Create a new person record in this workspace, optionally with an email address. ' + + 'Use this to add an external collaborator who does not have a Huly account.', + readOnly: false, + inputSchema: objectSchema( + { + name: stringProp('Full name.', { minLength: 1, maxLength: 200 }), + email: stringProp('Email address, used as the primary communication channel.') + }, + ['name'] + ), + handler: async (ctx, args) => { + const name = args.name as string + const personId = generateId() + + const attributes: Record = { + name, + avatar: null, + avatarProps: { color: 'blue' }, + personUuid: generateId(), + city: '', + comments: 0, + channels: args.email === undefined ? 0 : 1, + attachments: 0, + links: 0, + socialIds: 0 + } + await ctx.client.createDoc(contact.class.Person, contact.space.Contacts, attributes as never, personId) + + if (args.email !== undefined) { + const channel: Record = { + provider: contact.channelProvider.Email, + value: args.email + } + await ctx.client.addCollection( + contact.class.Channel, + contact.space.Contacts, + personId as never, + contact.class.Person, + 'channels', + channel as never + ) + } + + return textResult(JSON.stringify({ id: personId, name }, null, 2), { created: true, personId }) + } +} + +export const listSpacesTool: HulyTool = { + name: 'huly_list_spaces', + title: 'List spaces', + description: + 'List every typed space the user belongs to: projects, drives, document teamspaces and ' + + 'anything else that holds documents. Use the id to scope huly_list_documents.', + readOnly: true, + inputSchema: objectSchema({ + limit: { type: 'integer', description: 'Maximum spaces to return (1-200, default 100).', default: 100 } + }), + handler: async (ctx, args) => { + const query: Record = { members: ctx.account } + const spaces = (await ctx.client.findAll(core.class.TypedSpace, query as never, { + limit: clampLimit(args.limit), + sort: { name: SortingOrder.Ascending } + })) as unknown as Array<{ _id: string, name: string, type: string, archived?: boolean }> + + const rows = spaces + .filter((space) => space.archived !== true) + .map((space) => ({ id: space._id, name: space.name, type: space.type })) + + if (rows.length === 0) { + return textResult('No spaces found for this user.', { spaces: [] }) + } + + return textResult(JSON.stringify({ spaces: rows }, null, 2), { spaces: rows }) + } +} + +export const listDrivesTool: HulyTool = { + name: 'huly_list_drives', + title: 'List drives', + description: 'List the drives (file collections) in this workspace that the user belongs to.', + readOnly: true, + inputSchema: objectSchema({}), + handler: async (ctx) => { + const query: Record = { members: ctx.account } + const drives = (await ctx.client.findAll(drive.class.Drive, query as never, { + limit: 200, + sort: { name: SortingOrder.Ascending } + })) as unknown as Drive[] + + const rows = drives + .filter((item) => item.archived !== true) + .map((item) => ({ id: item._id, name: item.name, description: item.description ?? '' })) + + return textResult( + rows.length === 0 ? 'No drives found.' : JSON.stringify({ drives: rows }, null, 2), + { drives: rows } + ) + } +} + +export const listMilestonesTool: HulyTool = { + name: 'huly_list_milestones', + title: 'List milestones', + description: 'List the milestones of a project, with their target dates and how many issues are done.', + readOnly: true, + inputSchema: objectSchema({ projectId: stringProp('Project id.') }, ['projectId']), + handler: async (ctx, args) => { + const query: Record = { project: args.projectId } + const milestones = (await ctx.client.findAll( + tracker.class.Milestone, + query as never, + { limit: 200 } + )) as unknown as MilestoneRow[] + + const rows = milestones.map((milestone) => ({ + id: milestone._id, + name: milestone.name, + dueDate: toIso(milestone.dueDate), + doneCount: milestone.done?.length ?? 0 + })) + + return textResult( + rows.length === 0 + ? `No milestones in project ${args.projectId as string}.` + : JSON.stringify({ milestones: rows }, null, 2), + { milestones: rows } + ) + } +} + +/* -------------------------------------------------------------------------- */ + +export const personTools: HulyTool[] = [ + findPeopleTool, + createPersonTool, + listSpacesTool, + listDrivesTool, + listMilestonesTool, + listTasksTool +] diff --git a/pods/mcp/src/tools/project-tools.ts b/pods/mcp/src/tools/project-tools.ts new file mode 100644 index 0000000000..964b48b7b1 --- /dev/null +++ b/pods/mcp/src/tools/project-tools.ts @@ -0,0 +1,179 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { SortingOrder, type TxOperations } from '@hcengineering/core' +import task, { type Project as TaskProject } from '@hcengineering/task' +import tracker from '@hcengineering/tracker' + +import { textResult } from '../mcp/protocol' +import { booleanProp, objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool } from '../mcp/tool' +import { clampLimit } from './shared' + +/** Huly's to-do/kanban project class, distinct from a tracker project. */ +const taskProjectClass = task.class.Project + +/** Fields the project tools read. Structurally typed to keep the casts minimal. */ +interface ProjectRow { + _id: string + name: string + description?: string + identifier?: string + archived?: boolean + private?: boolean + defaultIssueStatus?: string + defaultAssignee?: string +} + +const summarize = (project: ProjectRow, kind: string, issueCount?: number): Record => ({ + id: project._id, + kind, + name: project.name, + ...(project.identifier === undefined ? {} : { identifier: project.identifier }), + description: project.description ?? '', + archived: project.archived === true, + private: project.private === true, + ...(issueCount === undefined ? {} : { issueCount }) +}) + +export const listProjectsTool: HulyTool = { + name: 'huly_list_projects', + title: 'List projects', + description: + 'List the projects in this workspace that the authenticated user is a member of. ' + + 'Returns tracker projects (issue tracking) and task projects (to-do / kanban boards) together. ' + + 'Use the returned "id" with the issue, task and document tools. ' + + 'Archived projects are excluded unless includeArchived is true.', + readOnly: true, + inputSchema: objectSchema({ + includeArchived: booleanProp('Include archived projects. Defaults to false.'), + limit: { type: 'integer', description: 'Maximum number of projects to return (1-200, default 50).', default: 50 } + }), + handler: async (ctx, args) => { + const includeArchived = args.includeArchived === true + const limit = clampLimit(args.limit) + const visibility: Record = { + members: ctx.account, + ...(includeArchived ? {} : { archived: false }) + } + + const [projects, taskProjects] = await Promise.all([ + ctx.client.findAll(tracker.class.Project, visibility as never, { + limit, + sort: { name: SortingOrder.Ascending } + }) as Promise, + ctx.client.findAll(taskProjectClass, visibility as never, { + limit, + sort: { name: SortingOrder.Ascending } + }) as Promise + ]) + + const projectRows = projects as ProjectRow[] + const taskRows = taskProjects as Array + const issueCounts = await countIssuesPerProject(ctx.client, projectRows.map((project) => project._id)) + + const payload = [ + ...projectRows.map((project) => summarize(project, 'tracker', issueCounts.get(project._id) ?? 0)), + ...taskRows.map((project) => summarize(project, 'task')) + ] + + if (payload.length === 0) { + return textResult( + 'No projects found. The user is not a member of any project, or every project is archived.', + { projects: [] } + ) + } + + return textResult(JSON.stringify({ projects: payload }, null, 2), { projects: payload }) + } +} + +export const getProjectTool: HulyTool = { + name: 'huly_get_project', + title: 'Get project details', + description: + 'Get a single project by id, including its default issue status and assignee. ' + + 'Accepts both tracker project ids and task project ids. ' + + 'Call huly_list_issue_statuses for the set of statuses an issue may be moved to.', + readOnly: true, + inputSchema: objectSchema( + { projectId: stringProp('Project id, as returned by huly_list_projects.') }, + ['projectId'] + ), + handler: async (ctx, args) => { + const projectId = args.projectId as string + + const project = (await ctx.client.findOne(tracker.class.Project, { _id: projectId } as never)) as + | unknown as ProjectRow + | undefined + + if (project !== undefined) { + return textResult( + JSON.stringify( + { + ...summarize(project, 'tracker'), + defaultIssueStatus: project.defaultIssueStatus ?? null, + defaultAssignee: project.defaultAssignee ?? null + }, + null, + 2 + ), + { found: true, kind: 'tracker' } + ) + } + + const taskProject = (await ctx.client.findOne(taskProjectClass, { _id: projectId } as never)) as + | unknown as ProjectRow + | undefined + + if (taskProject !== undefined) { + return textResult(JSON.stringify(summarize(taskProject, 'task'), null, 2), { + found: true, + kind: 'task' + }) + } + + return textResult( + `No project with id ${projectId} is visible to you. It may have been deleted, archived, ` + + 'or belong to a space you are not a member of. Use huly_list_projects to see what is available.', + { found: false } + ) + } +} + +/** + * Counts issues per project in a single pass. + * + * One projected `findAll` rather than N per-project counts: the whole point of + * listing projects is that a caller then asks about each one, and N+1 queries + * would dominate the latency of the tool. + */ +async function countIssuesPerProject (client: TxOperations, projectIds: string[]): Promise> { + const counts = new Map() + if (projectIds.length === 0) return counts + + const query: Record = { space: { $in: projectIds } } + const issues = (await client.findAll(tracker.class.Issue, query as never, { + limit: 5000, + projection: { space: 1 } + })) as unknown as Array<{ space: string }> + + for (const issue of issues) { + counts.set(issue.space, (counts.get(issue.space) ?? 0) + 1) + } + return counts +} + +export const projectTools: HulyTool[] = [listProjectsTool, getProjectTool] diff --git a/pods/mcp/src/tools/register.ts b/pods/mcp/src/tools/register.ts new file mode 100644 index 0000000000..a72d1b8b0d --- /dev/null +++ b/pods/mcp/src/tools/register.ts @@ -0,0 +1,57 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { ToolRegistry } from '../mcp/tool' +import { documentTools } from './document-tools' +import { issueTools } from './issue-tools' +import { personTools } from './people-tools' +import { projectTools } from './project-tools' +import { searchTools } from './search-tool' + +/** + * The complete tool surface of the server. + * + * Adding a capability means adding a file and appending it here; nothing else in + * the pod needs to change. Read tools come first so `tools/list` reads as a + * progression from "look" to "change" — the order is what a model sees. + */ +export function buildRegistry (): ToolRegistry { + return new ToolRegistry().registerAll([ + ...searchTools, + ...projectTools, + ...issueTools, + ...personTools, + ...documentTools + ]) +} + +/** Shown to the model after `initialize`; steers it to the right tool first. */ +export const MCP_INSTRUCTIONS = [ + 'This server exposes the Huly workspace of the authenticated user.', + '', + 'Getting started:', + '1. huly_search when you only know a topic; it returns ids for everything below.', + '2. huly_list_projects to see the projects the user belongs to.', + '3. huly_list_issues / huly_list_documents to read within a project or space.', + '', + 'Writing:', + '- Issue statuses are documents, not strings. Call huly_list_issue_statuses to get valid ids', + ' before huly_update_issue.', + '- People are referenced by id. Call huly_find_people to resolve a name to an id.', + '- Tools that modify data are refused when the server runs in read-only mode.', + '', + 'Ids are opaque strings. Always pass an id obtained from a list or search tool rather than', + 'guessing one, and read a project before creating issues in it.' +].join('\n') diff --git a/pods/mcp/src/tools/search-tool.ts b/pods/mcp/src/tools/search-tool.ts new file mode 100644 index 0000000000..215d313b1f --- /dev/null +++ b/pods/mcp/src/tools/search-tool.ts @@ -0,0 +1,102 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import contact from '@hcengineering/contact' +import doc from '@hcengineering/document' +import task from '@hcengineering/task' +import tracker from '@hcengineering/tracker' + +import { textResult } from '../mcp/protocol' +import { numberProp, objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool } from '../mcp/tool' +import { clampLimit } from './shared' + +/** Classes worth searching. Anything outside this list is noise for an agent. */ +const SEARCHABLE_CLASSES = [ + tracker.class.Issue, + tracker.class.Project, + tracker.class.Milestone, + doc.class.Document, + task.class.Task, + contact.class.Person +] as const + +/** Maps a class ref to a stable, human-meaningful type name for the response. */ +const TYPE_NAMES: Record = { + [tracker.class.Issue]: 'issue', + [tracker.class.Project]: 'project', + [tracker.class.Milestone]: 'milestone', + [doc.class.Document]: 'document', + [task.class.Task]: 'task', + [contact.class.Person]: 'person' +} + +export const searchTool: HulyTool = { + name: 'huly_search', + title: 'Full-text search', + description: + 'Full-text search across issues, projects, documents, tasks, milestones and people. ' + + 'This is the best starting point when you do not know the id of what you are looking for; ' + + 'returns ranked results with the matching snippet.', + readOnly: true, + inputSchema: objectSchema({ + query: stringProp('Search terms.', { minLength: 1, maxLength: 500 }), + types: { + type: 'array', + description: 'Restrict to certain object types.', + items: { type: 'string', enum: ['issue', 'project', 'milestone', 'document', 'task', 'person'] } + }, + limit: numberProp('Maximum results to return (1-50, default 20).', { + minimum: 1, + maximum: 50, + default: 20 + }) + }), + handler: async (ctx, args) => { + const query = String(args.query) + const limit = clampLimit(args.limit, 20) + + const types = (args.types as string[] | undefined) ?? [] + const classes = + types.length === 0 ? [...SEARCHABLE_CLASSES] : SEARCHABLE_CLASSES.filter((cls) => types.includes(TYPE_NAMES[cls])) + + if (classes.length === 0) { + return textResult(`No searchable type matches: ${types.join(', ')}.`, { results: [] }) + } + + const result = await ctx.client.searchFulltext({ query, classes: [...classes] }, { limit }) + + const results = result.docs.map((entry) => ({ + id: entry.id, + // `_class` lives on the nested `doc`, not on the search hit itself. + type: TYPE_NAMES[entry.doc?._class] ?? entry.doc?._class ?? 'unknown', + title: entry.title ?? entry.shortTitle ?? entry.id, + snippet: entry.description ?? null, + score: entry.score ?? null + })) + + if (results.length === 0) { + return textResult( + `No results for "${query}". The index may lag behind recent writes, or the terms may not appear ` + + 'verbatim — try fewer or more general words.', + { results: [] } + ) + } + + return textResult(JSON.stringify({ query, total: result.total ?? results.length, results }, null, 2), { results }) + } +} + +export const searchTools: HulyTool[] = [searchTool] diff --git a/pods/mcp/src/tools/shared.ts b/pods/mcp/src/tools/shared.ts new file mode 100644 index 0000000000..15aaf7b335 --- /dev/null +++ b/pods/mcp/src/tools/shared.ts @@ -0,0 +1,166 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import contact from '@hcengineering/contact' +import core, { type Class, type Doc, type Ref, type TxOperations } from '@hcengineering/core' +import task, { type Project as TaskProject } from '@hcengineering/task' +import tracker from '@hcengineering/tracker' + +/** Hard ceiling on rows any list tool may return, regardless of what is asked. */ +export const MAX_PAGE_SIZE = 200 +export const DEFAULT_PAGE_SIZE = 50 + +/** + * Clamps a caller-supplied page size. + * + * The bound is a correctness guard, not a preference: an unbounded `findAll` + * against a large project would try to serialise thousands of documents into a + * model response and blow up both latency and the model's context window. + */ +export function clampLimit (value: unknown, fallback: number = DEFAULT_PAGE_SIZE): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback + return Math.min(Math.max(1, Math.floor(value)), MAX_PAGE_SIZE) +} + +export function toIso (timestamp: number | null | undefined): string | null { + if (timestamp === null || timestamp === undefined || timestamp === 0) return null + return new Date(timestamp).toISOString() +} + +/** + * The id types crossing the tool boundary are plain strings. + * + * Tool arguments arrive as JSON, so a `Ref` in a tool signature is fiction + * that only the compiler believes. Accepting `string` here and casting once + * inside the query keeps the branded types where they are useful and stops + * every call site from needing a cast. + */ +export type MaybeId = string | null | undefined + +export function uniqueIds (values: MaybeId[]): string[] { + return [...new Set(values.filter((value): value is string => typeof value === 'string' && value.length > 0))] +} + +export async function findByIds ( + client: TxOperations, + _class: Ref>, + ids: MaybeId[] +): Promise { + const wanted = uniqueIds(ids) + if (wanted.length === 0) return [] + const query: Record = { _id: { $in: wanted } } + return await client.findAll(_class, query as never, { limit: wanted.length }) +} + +/** + * Resolves social ids to display names in one round trip. + * + * Person docs are workspace-local; the `AccountUuid` behind them is global. + * Tools only ever need the name, so the global id is never surfaced. + */ +export async function personNames (client: TxOperations, socialIds: MaybeId[]): Promise> { + const persons = await findByIds(client, contact.class.Person, socialIds) + return new Map(persons.map((person) => [person._id, person.name])) +} + +export interface StatusInfo { + name: string + category: string | null +} + +/** + * Resolves status ids to their human labels. + * + * `Status.name` is a plain string (it is what case-insensitive status matching + * keys off), so no i18n loader is needed on the server. The category *label* is + * an IntlString, so `defaultStatusName` is used as the readable stand-in. + */ +export async function statusNames (client: TxOperations, refs: MaybeId[]): Promise> { + const statuses = await findByIds(client, tracker.class.IssueStatus, refs) + const categories = await findByIds(client, core.class.StatusCategory, uniqueIds(statuses.map((s) => s.category))) + + const categoryByRef = new Map(categories.map((category) => [category._id, category.defaultStatusName])) + + return new Map( + statuses.map((status) => [ + status._id, + { + name: status.name, + category: status.category === undefined ? null : (categoryByRef.get(status.category) ?? null) + } + ]) + ) +} + +export interface ProjectInfo { + name: string + identifier: string | null +} + +interface ProjectRow { + _id: string + name: string + identifier?: string +} + +/** + * Resolves space ids to names for both tracker projects and task projects. + * + * They are separate classes in Huly but behave identically from a caller's + * point of view, so tools accept either kind of id. + */ +export async function projectNames (client: TxOperations, refs: MaybeId[]): Promise> { + const wanted = uniqueIds(refs) + if (wanted.length === 0) return new Map() + + // `findAll` widens these classes to their `Space` base, so the row shape is + // restated locally instead of borrowed from the plugin package. + const query: Record = { _id: { $in: wanted } } + const [projects, taskProjects] = await Promise.all([ + client.findAll(tracker.class.Project, query as never, { limit: wanted.length }), + client.findAll(task.class.Project, query as never, { limit: wanted.length }) + ]) + + const result = new Map() + for (const project of projects as unknown as ProjectRow[]) { + result.set(project._id, { name: project.name, identifier: project.identifier ?? null }) + } + for (const project of taskProjects as unknown as Array<{ _id: string, name: string }>) { + result.set(project._id, { name: project.name, identifier: null }) + } + return result +} + +/** Names a task project without the tracker lookup, for task-only listings. */ +export async function taskProjectNames ( + client: TxOperations, + refs: MaybeId[] +): Promise> { + const wanted = uniqueIds(refs) + if (wanted.length === 0) return new Map() + + const query: Record = { _id: { $in: wanted } } + const projects = await client.findAll(task.class.Project, query as never, { limit: wanted.length }) + return new Map( + (projects as unknown as Array<{ _id: string, name: string }>).map((project) => [ + project._id, + { name: project.name, identifier: null } + ]) + ) +} + +/** Casts a JSON-supplied id to a `Ref` for use in a typed query. */ +export const asRef = (id: string): Ref => id as Ref +export const asTaskProjectRef = asRef diff --git a/pods/mcp/tsconfig.json b/pods/mcp/tsconfig.json new file mode 100644 index 0000000000..0679c9ef1d --- /dev/null +++ b/pods/mcp/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} diff --git a/rush.json b/rush.json index b46ef7ebb6..7bd1506478 100644 --- a/rush.json +++ b/rush.json @@ -1616,6 +1616,11 @@ "projectFolder": "pods/link-preview", "shouldPublish": false }, + { + "packageName": "@hcengineering/pod-mcp", + "projectFolder": "pods/mcp", + "shouldPublish": false + }, { "packageName": "@hcengineering/hr", "projectFolder": "plugins/hr", From 12c8c83e05101edad0dbd63c726812df94e19873 Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:48:37 +0530 Subject: [PATCH 02/32] style(mcp): apply repo prettier and eslint formatting Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com> --- pods/mcp/src/auth/configured-authenticator.ts | 4 +- pods/mcp/src/auth/huly-token-authenticator.ts | 8 ++- pods/mcp/src/mcp/__tests__/validation.test.ts | 6 +- pods/mcp/src/mcp/dispatcher.ts | 2 +- pods/mcp/src/mcp/session.ts | 4 +- pods/mcp/src/mcp/tool.ts | 6 +- pods/mcp/src/mcp/validation.ts | 13 +--- pods/mcp/src/server.ts | 24 ++++--- pods/mcp/src/tools/document-tools.ts | 5 +- pods/mcp/src/tools/issue-tools.ts | 71 ++++++++++++------- pods/mcp/src/tools/people-tools.ts | 35 +++++---- pods/mcp/src/tools/project-tools.ts | 26 +++---- pods/mcp/src/tools/shared.ts | 7 +- 13 files changed, 112 insertions(+), 99 deletions(-) diff --git a/pods/mcp/src/auth/configured-authenticator.ts b/pods/mcp/src/auth/configured-authenticator.ts index 14c72bb802..84ed506203 100644 --- a/pods/mcp/src/auth/configured-authenticator.ts +++ b/pods/mcp/src/auth/configured-authenticator.ts @@ -138,8 +138,6 @@ export class ConfiguredAuthenticator implements Authenticator { /** Accepts a workspace id, its url slug, or a case-insensitive id. */ function matchesWorkspace (loginInfo: { workspace: string, workspaceUrl: string }, wanted: string): boolean { return ( - loginInfo.workspace === wanted || - loginInfo.workspaceUrl === wanted || - loginInfo.workspace === wanted.toLowerCase() + loginInfo.workspace === wanted || loginInfo.workspaceUrl === wanted || loginInfo.workspace === wanted.toLowerCase() ) } diff --git a/pods/mcp/src/auth/huly-token-authenticator.ts b/pods/mcp/src/auth/huly-token-authenticator.ts index cc7ee56fed..79b87523d1 100644 --- a/pods/mcp/src/auth/huly-token-authenticator.ts +++ b/pods/mcp/src/auth/huly-token-authenticator.ts @@ -13,11 +13,15 @@ limitations under the License. */ -import { getClient as getAccountClient, isWorkspaceLoginInfo, type AccountClient, type LoginInfoByToken } from '@hcengineering/account-client' +import { + getClient as getAccountClient, + isWorkspaceLoginInfo, + type AccountClient, + type LoginInfoByToken +} from '@hcengineering/account-client' import { type MeasureContext } from '@hcengineering/core' import { setApiTokenRevocationChecker, verifyToken } from '@hcengineering/server-token' -import { type Config } from '../config' import { AuthenticationError, type Authenticator, type SessionIdentity, toTransactorHttpUrl } from './authenticator' export interface HulyTokenAuthenticatorOptions { diff --git a/pods/mcp/src/mcp/__tests__/validation.test.ts b/pods/mcp/src/mcp/__tests__/validation.test.ts index 9f76c53fae..15c59b2f0d 100644 --- a/pods/mcp/src/mcp/__tests__/validation.test.ts +++ b/pods/mcp/src/mcp/__tests__/validation.test.ts @@ -37,11 +37,11 @@ const schema: JsonSchema = { * comparison also documents intent: these assertions care about *which* branch * they took, not merely whether validation passed. */ -const issuesOf = (result: ValidationResult): string[] => (result.ok === true ? [] : result.issues) +const issuesOf = (result: ValidationResult): string[] => (result.ok ? [] : result.issues) -const valueOf = (result: ValidationResult): Record => (result.ok === true ? result.value : {}) +const valueOf = (result: ValidationResult): Record => (result.ok ? result.value : {}) -const isOk = (result: ValidationResult): boolean => result.ok === true +const isOk = (result: ValidationResult): boolean => result.ok describe('validateArguments', () => { it('accepts a minimal valid payload', () => { diff --git a/pods/mcp/src/mcp/dispatcher.ts b/pods/mcp/src/mcp/dispatcher.ts index 514f419c1d..5f3840dc43 100644 --- a/pods/mcp/src/mcp/dispatcher.ts +++ b/pods/mcp/src/mcp/dispatcher.ts @@ -98,7 +98,7 @@ export class McpDispatcher { } } - const request = message as JsonRpcRequest + const request = message // Guard every method except initialize: a client that skips the handshake // would otherwise be able to call tools with no negotiated protocol version. diff --git a/pods/mcp/src/mcp/session.ts b/pods/mcp/src/mcp/session.ts index 7b7dc55924..a7814db30d 100644 --- a/pods/mcp/src/mcp/session.ts +++ b/pods/mcp/src/mcp/session.ts @@ -45,9 +45,7 @@ export class McpSession { } belongsTo (identity: SessionIdentity): boolean { - return ( - this.identity.account === identity.account && this.identity.workspace === identity.workspace - ) + return this.identity.account === identity.account && this.identity.workspace === identity.workspace } } diff --git a/pods/mcp/src/mcp/tool.ts b/pods/mcp/src/mcp/tool.ts index 4f2fbf8e1e..d4fc135617 100644 --- a/pods/mcp/src/mcp/tool.ts +++ b/pods/mcp/src/mcp/tool.ts @@ -111,11 +111,7 @@ export class ToolRegistry { * `isError` results lets the model self-correct; the transport stays a * successful JSON-RPC call. */ - async call ( - name: string, - rawArgs: unknown, - context: ToolContext - ): Promise { + async call (name: string, rawArgs: unknown, context: ToolContext): Promise { const tool = this.tools.get(name) if (tool === undefined) { const available = [...this.tools.keys()].join(', ') diff --git a/pods/mcp/src/mcp/validation.ts b/pods/mcp/src/mcp/validation.ts index 77b2897c5d..f44c70932d 100644 --- a/pods/mcp/src/mcp/validation.ts +++ b/pods/mcp/src/mcp/validation.ts @@ -22,9 +22,7 @@ import { type JsonSchema, type JsonSchemaType, type ToolArguments } from './sche // a malformed Huly query. It therefore fails fast and reports every issue at // once, so an agent can correct the whole call in one round trip. -export type ValidationResult = - | { ok: true, value: ToolArguments } - | { ok: false, issues: string[] } +export type ValidationResult = { ok: true, value: ToolArguments } | { ok: false, issues: string[] } const MAX_PATTERN_LENGTH = 512 const patternCache = new Map() @@ -123,12 +121,7 @@ function validateValue (value: unknown, schema: JsonSchema, path: string, issues } } -function validateObject ( - value: Record, - schema: JsonSchema, - path: string, - issues: string[] -): void { +function validateObject (value: Record, schema: JsonSchema, path: string, issues: string[]): void { const properties = schema.properties ?? {} const required = schema.required ?? [] @@ -158,7 +151,7 @@ function validateObject ( */ export function validateArguments (schema: JsonSchema, args: unknown): ValidationResult { const issues: string[] = [] - const input = args === undefined || args === null ? {} : args + const input = args ?? {} if (!isPlainObject(input)) { return { ok: false, issues: ['arguments must be an object'] } diff --git a/pods/mcp/src/server.ts b/pods/mcp/src/server.ts index 829521e35d..3350eb99e5 100644 --- a/pods/mcp/src/server.ts +++ b/pods/mcp/src/server.ts @@ -136,17 +136,19 @@ export function createServer (deps: ServerDependencies): McpServer { app.get('/api/v1/statistics', statistics(ctx, config)) app.get('/', (_req: Request, res: Response) => { - res.type('text/plain').send( - [ - 'Huly MCP Server', - '', - `MCP endpoint: POST ${MCP_ENDPOINT}`, - 'Health: GET /api/v1/health', - `Auth mode: ${config.AuthMode}${config.ReadOnly ? ' (read-only)' : ''}`, - `Tools: ${registry.size}`, - '' - ].join('\n') - ) + res + .type('text/plain') + .send( + [ + 'Huly MCP Server', + '', + `MCP endpoint: POST ${MCP_ENDPOINT}`, + 'Health: GET /api/v1/health', + `Auth mode: ${config.AuthMode}${config.ReadOnly ? ' (read-only)' : ''}`, + `Tools: ${registry.size}`, + '' + ].join('\n') + ) }) app.use((_req: Request, res: Response) => { diff --git a/pods/mcp/src/tools/document-tools.ts b/pods/mcp/src/tools/document-tools.ts index 205ffc291c..98912adfec 100644 --- a/pods/mcp/src/tools/document-tools.ts +++ b/pods/mcp/src/tools/document-tools.ts @@ -95,8 +95,9 @@ export const getDocumentTool: HulyTool = { handler: async (ctx, args) => { const documentId = args.documentId as string - const document = (await ctx.client.findOne(doc.class.Document, { _id: documentId } as never)) as - | unknown as DocumentRow + const documentIdQuery: Record = { _id: documentId } + const document = (await ctx.client.findOne(doc.class.Document, documentIdQuery as never)) as unknown as + | DocumentRow | undefined if (document === undefined) { diff --git a/pods/mcp/src/tools/issue-tools.ts b/pods/mcp/src/tools/issue-tools.ts index 95402036d3..c6c3b7a9a2 100644 --- a/pods/mcp/src/tools/issue-tools.ts +++ b/pods/mcp/src/tools/issue-tools.ts @@ -60,9 +60,18 @@ const uniqueStatuses = (issues: IssueRow[]): string[] => uniqueIds(issues.map((i */ async function formatIssues (ctx: ToolContext, issues: IssueRow[]): Promise>> { const [people, statuses, projects] = await Promise.all([ - personNames(ctx.client, issues.map((issue) => issue.assignee)), - statusNames(ctx.client, issues.map((issue) => issue.status)), - projectNames(ctx.client, issues.map((issue) => issue.space)) + personNames( + ctx.client, + issues.map((issue) => issue.assignee) + ), + statusNames( + ctx.client, + issues.map((issue) => issue.status) + ), + projectNames( + ctx.client, + issues.map((issue) => issue.space) + ) ]) return issues.map((issue) => { @@ -162,8 +171,9 @@ export const getIssueTool: HulyTool = { handler: async (ctx, args) => { const issueId = args.issueId as string - const issue = (await ctx.client.findOne(tracker.class.Issue, { _id: issueId } as never)) as - | unknown as IssueRow + const issueIdQuery: Record = { _id: issueId } + const issue = (await ctx.client.findOne(tracker.class.Issue, issueIdQuery as never)) as unknown as + | IssueRow | undefined if (issue === undefined) { @@ -210,7 +220,10 @@ export const getIssueTool: HulyTool = { async function formatComments (ctx: ToolContext, comments: ChatMessage[]): Promise>> { if (comments.length === 0) return [] - const people = await personNames(ctx.client, comments.map((comment) => comment.createdBy as never)) + const people = await personNames( + ctx.client, + comments.map((comment) => comment.createdBy as never) + ) return comments.map((comment) => ({ id: comment._id, author: people.get(comment.createdBy as never) ?? 'Unknown', @@ -233,7 +246,10 @@ export const listIssueStatusesTool: HulyTool = { limit: 200 })) as unknown as Array<{ _id: string, name: string }> - const resolved = await statusNames(ctx.client, statuses.map((status) => status._id)) + const resolved = await statusNames( + ctx.client, + statuses.map((status) => status._id) + ) const rows = statuses.map((status) => ({ id: status._id, @@ -274,15 +290,15 @@ export const createIssueTool: HulyTool = { handler: async (ctx, args) => { const projectId = args.projectId as string - const project = (await ctx.client.findOne(tracker.class.Project, { _id: projectId } as never)) as - | unknown as ProjectDefaults + const projectIdQuery: Record = { _id: projectId } + const project = (await ctx.client.findOne(tracker.class.Project, projectIdQuery as never)) as unknown as + | ProjectDefaults | undefined if (project === undefined) { - return textResult( - `No project with id ${projectId} is visible to you, so the issue was not created.`, - { created: false } - ) + return textResult(`No project with id ${projectId} is visible to you, so the issue was not created.`, { + created: false + }) } const statusId = (args.statusId as string | undefined) ?? project.defaultIssueStatus @@ -348,7 +364,11 @@ export const updateIssueTool: HulyTool = { issueId: stringProp('Issue id from huly_list_issues.'), title: stringProp('New title.', { maxLength: 500 }), statusId: stringProp('New status id from huly_list_issue_statuses.'), - priority: { type: 'string', description: 'New priority.', enum: ['NoPriority', 'Urgent', 'High', 'Medium', 'Low'] }, + priority: { + type: 'string', + description: 'New priority.', + enum: ['NoPriority', 'Urgent', 'High', 'Medium', 'Low'] + }, assignee: stringProp('New assignee person id, or null to unassign.'), dueDate: stringProp('New ISO-8601 due date, or null to clear it.'), startDate: stringProp('New ISO-8601 start date, or null to clear it.'), @@ -359,8 +379,9 @@ export const updateIssueTool: HulyTool = { handler: async (ctx, args) => { const issueId = args.issueId as string - const issue = (await ctx.client.findOne(tracker.class.Issue, { _id: issueId } as never)) as - | unknown as IssueRow + const issueIdQuery: Record = { _id: issueId } + const issue = (await ctx.client.findOne(tracker.class.Issue, issueIdQuery as never)) as unknown as + | IssueRow | undefined if (issue === undefined) { @@ -407,8 +428,9 @@ export const addCommentTool: HulyTool = { ), handler: async (ctx, args) => { const issueId = args.issueId as string - const issue = (await ctx.client.findOne(tracker.class.Issue, { _id: issueId } as never)) as - | unknown as IssueRow + const issueIdQuery: Record = { _id: issueId } + const issue = (await ctx.client.findOne(tracker.class.Issue, issueIdQuery as never)) as unknown as + | IssueRow | undefined if (issue === undefined) { @@ -477,18 +499,15 @@ export const createMilestoneTool: HulyTool = { const attached: string[] = [] for (const issueId of issueIds) { - const issue = (await ctx.client.findOne(tracker.class.Issue, { _id: issueId } as never)) as - | unknown as IssueRow + const issueIdQuery: Record = { _id: issueId } + const issue = (await ctx.client.findOne(tracker.class.Issue, issueIdQuery as never)) as unknown as + | IssueRow | undefined // Silently skipping unknown ids would make a partial failure look like a // complete one, so unattached ids are reported back to the caller. if (issue === undefined || issue.space !== projectId) continue - await ctx.client.updateDoc( - tracker.class.Issue, - issue.space as never, - issue._id as never, - { milestone: milestoneId } as never - ) + const attach: Record = { milestone: milestoneId } + await ctx.client.updateDoc(tracker.class.Issue, issue.space as never, issue._id as never, attach as never) attached.push(issue._id) } diff --git a/pods/mcp/src/tools/people-tools.ts b/pods/mcp/src/tools/people-tools.ts index 5f6e075509..8f7ac77bab 100644 --- a/pods/mcp/src/tools/people-tools.ts +++ b/pods/mcp/src/tools/people-tools.ts @@ -49,8 +49,7 @@ export const listTasksTool: HulyTool = { name: 'huly_list_tasks', title: 'List tasks', description: - 'List to-do tasks and subtasks, optionally scoped to one task project (board). ' + - 'Returns at most 200 per call.', + 'List to-do tasks and subtasks, optionally scoped to one task project (board). ' + 'Returns at most 200 per call.', readOnly: true, inputSchema: objectSchema({ projectId: stringProp('Task project (board) id from huly_list_projects.'), @@ -77,9 +76,18 @@ export const listTasksTool: HulyTool = { } const [people, statuses, projects] = await Promise.all([ - personNames(ctx.client, rows.map((row) => row.assignee)), - statusNames(ctx.client, rows.map((row) => row.status)), - taskProjectNames(ctx.client, rows.map((row) => row.space)) + personNames( + ctx.client, + rows.map((row) => row.assignee) + ), + statusNames( + ctx.client, + rows.map((row) => row.status) + ), + taskProjectNames( + ctx.client, + rows.map((row) => row.space) + ) ]) const tasks = rows.map((row) => ({ @@ -242,13 +250,12 @@ export const listDrivesTool: HulyTool = { })) as unknown as Drive[] const rows = drives - .filter((item) => item.archived !== true) + .filter((item) => !item.archived) .map((item) => ({ id: item._id, name: item.name, description: item.description ?? '' })) - return textResult( - rows.length === 0 ? 'No drives found.' : JSON.stringify({ drives: rows }, null, 2), - { drives: rows } - ) + return textResult(rows.length === 0 ? 'No drives found.' : JSON.stringify({ drives: rows }, null, 2), { + drives: rows + }) } } @@ -260,11 +267,9 @@ export const listMilestonesTool: HulyTool = { inputSchema: objectSchema({ projectId: stringProp('Project id.') }, ['projectId']), handler: async (ctx, args) => { const query: Record = { project: args.projectId } - const milestones = (await ctx.client.findAll( - tracker.class.Milestone, - query as never, - { limit: 200 } - )) as unknown as MilestoneRow[] + const milestones = (await ctx.client.findAll(tracker.class.Milestone, query as never, { + limit: 200 + })) as unknown as MilestoneRow[] const rows = milestones.map((milestone) => ({ id: milestone._id, diff --git a/pods/mcp/src/tools/project-tools.ts b/pods/mcp/src/tools/project-tools.ts index 964b48b7b1..3a293ac60d 100644 --- a/pods/mcp/src/tools/project-tools.ts +++ b/pods/mcp/src/tools/project-tools.ts @@ -82,7 +82,10 @@ export const listProjectsTool: HulyTool = { const projectRows = projects as ProjectRow[] const taskRows = taskProjects as Array - const issueCounts = await countIssuesPerProject(ctx.client, projectRows.map((project) => project._id)) + const issueCounts = await countIssuesPerProject( + ctx.client, + projectRows.map((project) => project._id) + ) const payload = [ ...projectRows.map((project) => summarize(project, 'tracker', issueCounts.get(project._id) ?? 0)), @@ -90,10 +93,9 @@ export const listProjectsTool: HulyTool = { ] if (payload.length === 0) { - return textResult( - 'No projects found. The user is not a member of any project, or every project is archived.', - { projects: [] } - ) + return textResult('No projects found. The user is not a member of any project, or every project is archived.', { + projects: [] + }) } return textResult(JSON.stringify({ projects: payload }, null, 2), { projects: payload }) @@ -108,15 +110,13 @@ export const getProjectTool: HulyTool = { 'Accepts both tracker project ids and task project ids. ' + 'Call huly_list_issue_statuses for the set of statuses an issue may be moved to.', readOnly: true, - inputSchema: objectSchema( - { projectId: stringProp('Project id, as returned by huly_list_projects.') }, - ['projectId'] - ), + inputSchema: objectSchema({ projectId: stringProp('Project id, as returned by huly_list_projects.') }, ['projectId']), handler: async (ctx, args) => { const projectId = args.projectId as string - const project = (await ctx.client.findOne(tracker.class.Project, { _id: projectId } as never)) as - | unknown as ProjectRow + const projectIdQuery: Record = { _id: projectId } + const project = (await ctx.client.findOne(tracker.class.Project, projectIdQuery as never)) as unknown as + | ProjectRow | undefined if (project !== undefined) { @@ -134,8 +134,8 @@ export const getProjectTool: HulyTool = { ) } - const taskProject = (await ctx.client.findOne(taskProjectClass, { _id: projectId } as never)) as - | unknown as ProjectRow + const taskProject = (await ctx.client.findOne(taskProjectClass, projectIdQuery as never)) as unknown as + | ProjectRow | undefined if (taskProject !== undefined) { diff --git a/pods/mcp/src/tools/shared.ts b/pods/mcp/src/tools/shared.ts index 15aaf7b335..8d6951ff10 100644 --- a/pods/mcp/src/tools/shared.ts +++ b/pods/mcp/src/tools/shared.ts @@ -144,10 +144,7 @@ export async function projectNames (client: TxOperations, refs: MaybeId[]): Prom } /** Names a task project without the tracker lookup, for task-only listings. */ -export async function taskProjectNames ( - client: TxOperations, - refs: MaybeId[] -): Promise> { +export async function taskProjectNames (client: TxOperations, refs: MaybeId[]): Promise> { const wanted = uniqueIds(refs) if (wanted.length === 0) return new Map() @@ -162,5 +159,5 @@ export async function taskProjectNames ( } /** Casts a JSON-supplied id to a `Ref` for use in a typed query. */ -export const asRef = (id: string): Ref => id as Ref +export const asRef = (id: string): Ref => id as Ref export const asTaskProjectRef = asRef From b3b3130b0bfac9cfa852a2a9000503ea1bb49752 Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:51:18 +0530 Subject: [PATCH 03/32] fix(mcp): return 413 for oversized request bodies; document smoke test 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> --- docs/agentlog.md | 12 ++++ pods/mcp/README.md | 19 +++++ .../__tests__/error-handler.test.ts | 69 +++++++++++++++++++ pods/mcp/src/middleware/index.ts | 12 ++++ 4 files changed, 112 insertions(+) create mode 100644 pods/mcp/src/middleware/__tests__/error-handler.test.ts diff --git a/docs/agentlog.md b/docs/agentlog.md index 18329e8cb9..5f4b55167f 100644 --- a/docs/agentlog.md +++ b/docs/agentlog.md @@ -19,3 +19,15 @@ Appended by each agent so work can be resumed across sessions. Read only the tai [2026-09-30] FIXED: first `rush update` failed — `eslint-plugin-promise@^6.21.0` does not exist (latest is 7.3.0). Corrected to `^6.1.1` to match `pods/link-preview`. Also corrected `api-client` to `workspace:^0.7.19` and `analytics-service` to `workspace:^0.7.18` to match the real package versions; the wrong values would have failed `rush check` and `common/scripts/check-versions.js` in CI. [2026-09-30] NEXT: verify `rush build --to @hcengineering/pod-mcp` and `rushx test` pass, then open the PR against `develop`. + +[2026-09-30] CORRECTION to the entry above: the dependency ranges that pass `rush check` and `check-versions.js` follow repo convention (sibling pods declare the higher range: `api-client` `^0.7.25`, `analytics-service` `^0.7.19`), not the literal package.json versions. Build, validate, `rush check`, `check-versions.js`, `tsc --noEmit` and jest are all green. + +[2026-09-30] Runtime smoke test WITHOUT a live Huly (pod started via ts-node, `SECRET` set, `ACCOUNTS_URL` unreachable): `/api/v1/health` 200 with 18 tools; missing/garbage/forged bearer → 401; malformed JSON → 400; GET/DELETE without `Mcp-Session-Id` → 400; CORS preflight 204; unknown route 404; boot refuses `SECRET` unset or `secret`. FOUND AND FIXED: an oversized body returned 500 because `errorHandler` did not map body-parser errors; it now returns 413 (regression tests in `src/middleware/__tests__/error-handler.test.ts`). + +[2026-09-30] NOT VERIFIED: no tool handler has run against a real Huly workspace, and the authenticated `initialize` → `tools/list` → `tools/call` path needs a live account service. Treat tool handlers as unproven at runtime until someone runs the smoke test from `pods/mcp/README.md` against a dev stack. + +[2026-09-30] Known non-issue: `rush test --to @hcengineering/pod-mcp` also runs upstream tests, and `@hcengineering/measurements` `src/__tests__/performance.test.ts` has a timing assertion that flakes under parallel load. It passes 70/70 standalone and this branch does not touch it. Do not chase it. + +[2026-09-30] FOR MAINTAINER REVIEW: `huly_create_issue` derives the next issue number as `max(number)+1` because Huly's numbering middleware is not vendored here, so two concurrent creates in one project can collide. Needs a decision (numbering hint, server-side sequence, or a follow-up). + +[2026-09-30] WARNING: never run `node_modules/.bin/format` or `rushx format` inside a package dir — it once emptied 24 source files in pods/mcp. Use only `node common/scripts/install-run-rush.js fast-format --branch develop` from the repo root, and check `find pods/mcp/src -name '*.ts' -size 0` afterwards. diff --git a/pods/mcp/README.md b/pods/mcp/README.md index abc9252144..4360e31395 100644 --- a/pods/mcp/README.md +++ b/pods/mcp/README.md @@ -141,3 +141,22 @@ rushx run-local # needs SECRET, ACCOUNTS_URL and credentials The MCP protocol layer has no dependency on Huly: `src/mcp/` is transport- and platform-agnostic and unit tested on its own. + +### Smoke test against a running platform + +With the dev stack up (`cd dev && docker compose up -d`) and `HULY_TOKEN` (or +`HULY_EMAIL` + `HULY_PASSWORD`) set on the `mcp` service: + +```bash +curl -s localhost:4090/api/v1/health + +# initialize: expect HTTP 200, an Mcp-Session-Id response header and a protocolVersion echo +curl -si localhost:4090/mcp -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' + +# then reuse the returned session id +curl -s localhost:4090/mcp -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' -H "Mcp-Session-Id: $SID" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"huly_list_projects","arguments":{}}}' +``` diff --git a/pods/mcp/src/middleware/__tests__/error-handler.test.ts b/pods/mcp/src/middleware/__tests__/error-handler.test.ts new file mode 100644 index 0000000000..87f9b2b5a0 --- /dev/null +++ b/pods/mcp/src/middleware/__tests__/error-handler.test.ts @@ -0,0 +1,69 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type NextFunction, type Request, type Response } from 'express' + +import { fakeMeasureContext } from '../../__tests__/test-doubles' +import { HttpError } from '../../error' +import { errorHandler } from '../index' + +interface Captured { + status: number + body: unknown +} + +function run (err: unknown): Captured { + const captured: Captured = { status: 0, body: undefined } + const res = { + headersSent: false, + status (code: number) { + captured.status = code + return this + }, + json (body: unknown) { + captured.body = body + return this + } + } + const req: Pick = { path: '/mcp' } + errorHandler(fakeMeasureContext())(err, req as Request, res as unknown as Response, (() => {}) as NextFunction) + return captured +} + +describe('errorHandler', () => { + it('maps HttpError to its own status', () => { + expect(run(new HttpError(403, 'nope')).status).toBe(403) + }) + + it('reports malformed JSON as 400', () => { + expect(run(Object.assign(new SyntaxError('bad'), { type: 'entity.parse.failed' })).status).toBe(400) + }) + + it('reports an oversized body as 413, not 500', () => { + const err = Object.assign(new Error('too large'), { type: 'entity.too.large', status: 413, expose: true }) + expect(run(err).status).toBe(413) + }) + + it('passes through other exposed client errors', () => { + const err = Object.assign(new Error('unsupported'), { status: 415, expose: true }) + expect(run(err).status).toBe(415) + }) + + it('hides unexpected failures behind a 500', () => { + const captured = run(new Error('db password is hunter2')) + expect(captured.status).toBe(500) + expect(JSON.stringify(captured.body)).not.toContain('hunter2') + }) +}) diff --git a/pods/mcp/src/middleware/index.ts b/pods/mcp/src/middleware/index.ts index 6439bc6bb5..5c6c47c866 100644 --- a/pods/mcp/src/middleware/index.ts +++ b/pods/mcp/src/middleware/index.ts @@ -107,6 +107,18 @@ export const errorHandler = (ctx: MeasureContext): ErrorRequestHandler => { return } + // Body parser rejections (payload too large, unsupported charset, aborted + // request) carry a 4xx status and are safe to report as-is. + if (err?.type === 'entity.too.large') { + res.status(413).json({ error: 'Request body is too large' }) + return + } + const status = typeof err?.status === 'number' ? err.status : 0 + if (err?.expose === true && status >= 400 && status < 500) { + res.status(status).json({ error: 'Bad request' }) + return + } + ctx.error('mcp unhandled error', { error: err?.message, path: req.path }) Analytics.handleError(err) res.status(500).json({ error: 'Internal Server Error' }) From 5a5b1602ad736725d37e9d241ec974afcb2f08f3 Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:16:40 +0530 Subject: [PATCH 04/32] fix(mcp): repair tools and auth found by a live smoke test against Huly 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> --- ARCHITECTURE_OVERVIEW.md | 5 +- common/config/rush/pnpm-lock.yaml | 9 + dev/docker-compose.yaml | 5 + pods/mcp/README.md | 14 +- pods/mcp/package.json | 3 + pods/mcp/src/__tests__/config.test.ts | 38 +++ .../configured-authenticator.test.ts | 140 +++++++++ pods/mcp/src/auth/configured-authenticator.ts | 32 +- pods/mcp/src/config.ts | 16 +- pods/mcp/src/index.ts | 7 +- .../__tests__/registry-and-session.test.ts | 7 + pods/mcp/src/mcp/dispatcher.ts | 2 +- pods/mcp/src/mcp/tool.ts | 6 +- .../mcp/src/platform/__tests__/markup.test.ts | 41 +++ pods/mcp/src/platform/collaborator-writer.ts | 43 +++ pods/mcp/src/platform/markup-reader.ts | 9 + pods/mcp/src/platform/markup.ts | 39 +++ .../src/platform/workspace-client-provider.ts | 13 +- pods/mcp/src/tools/__tests__/tools.test.ts | 275 ++++++++++++++++++ pods/mcp/src/tools/document-tools.ts | 8 +- pods/mcp/src/tools/issue-tools.ts | 197 +++++++++---- pods/mcp/src/tools/people-tools.ts | 107 +++++-- pods/mcp/src/tools/project-tools.ts | 5 +- pods/mcp/src/tools/shared.ts | 10 +- 24 files changed, 915 insertions(+), 116 deletions(-) create mode 100644 pods/mcp/src/__tests__/config.test.ts create mode 100644 pods/mcp/src/auth/__tests__/configured-authenticator.test.ts create mode 100644 pods/mcp/src/platform/__tests__/markup.test.ts create mode 100644 pods/mcp/src/platform/collaborator-writer.ts create mode 100644 pods/mcp/src/platform/markup.ts create mode 100644 pods/mcp/src/tools/__tests__/tools.test.ts diff --git a/ARCHITECTURE_OVERVIEW.md b/ARCHITECTURE_OVERVIEW.md index 396d976c8c..7f372ed57d 100644 --- a/ARCHITECTURE_OVERVIEW.md +++ b/ARCHITECTURE_OVERVIEW.md @@ -391,7 +391,7 @@ sequenceDiagram | process | platformcollective/process | - | Workflow automation | redpanda, account | | rating | platformcollective/rating | - | Content rating | cockroach, redpanda, account | | **AI** | | | | | -| mcp | platformcollective/mcp | 4090 | Model Context Protocol server (Streamable HTTP) | account, transactor, stats | +| mcp | platformcollective/mcp | 4090 | Model Context Protocol server (Streamable HTTP) | account, transactor, collaborator | | **Backup** | | | | | | backup | platformcollective/backup | - | Automated backup | cockroach, minio, account | | backup-api | platformcollective/backup-api | 4039 | Backup REST API | minio, account | @@ -439,6 +439,9 @@ sequenceDiagram - `HULY_TOKEN`: Huly API token for the pod's own identity. Setting it selects self-hosted mode, where MCP clients need no Huly credential. - `HULY_EMAIL` / `HULY_PASSWORD`: Login used when no `HULY_TOKEN` is set - `HULY_WORKSPACE`: Pin the configured account to a single workspace +- `COLLABORATOR_URL`: Collaborator service URL; enables writing rich-text descriptions +- `HULY_WORKSPACE`: Workspace url slug or id the configured account is scoped to +- `MCP_ALLOW_DEFAULT_SECRET`: `false` - Dev stacks only; permits `SECRET=secret` - `MCP_READONLY`: `false` - Refuse every write tool - `MCP_ALLOWED_TOKENS`: Comma-separated token allowlist for multi-tenant mode - `MCP_RATE_LIMIT` / `MCP_RATE_WINDOW_MS`: `300` / `60000` - Per-client rate limit diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index b859ca389e..96bba146ca 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -31060,6 +31060,9 @@ importers: '@hcengineering/chunter': specifier: workspace:^0.7.0 version: link:../../plugins/chunter + '@hcengineering/collaborator-client': + specifier: workspace:^0.7.18 + version: link:../../foundations/core/packages/collaborator-client '@hcengineering/contact': specifier: workspace:^0.7.0 version: link:../../plugins/contact @@ -31084,6 +31087,12 @@ importers: '@hcengineering/task': specifier: workspace:^0.7.0 version: link:../../plugins/task + '@hcengineering/text-core': + specifier: workspace:^0.7.19 + version: link:../../foundations/core/packages/text-core + '@hcengineering/text-markdown': + specifier: workspace:^0.7.21 + version: link:../../foundations/core/packages/text-markdown '@hcengineering/tracker': specifier: workspace:^0.7.0 version: link:../../plugins/tracker diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index b6cce44736..402013808a 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -58,12 +58,17 @@ services: container_name: mcp environment: - SECRET=secret + # The dev stack uses the default platform secret everywhere. pod-mcp refuses it + # unless told this is a throwaway development stack. + - MCP_ALLOW_DEFAULT_SECRET=true - PORT=4090 - ACCOUNTS_URL=http://huly.local:3000 + - COLLABORATOR_URL=http://collaborator:3078 # Set HULY_TOKEN (or HULY_EMAIL + HULY_PASSWORD) to run in self-hosted # mode. Without it the server expects each MCP client to send its own # Huly API token, which is not practical from Claude Desktop. # - HULY_TOKEN=${HULY_TOKEN} + # - HULY_WORKSPACE=${HULY_WORKSPACE} ports: - 4090:4090 restart: unless-stopped diff --git a/pods/mcp/README.md b/pods/mcp/README.md index 4360e31395..ae052b9380 100644 --- a/pods/mcp/README.md +++ b/pods/mcp/README.md @@ -39,7 +39,8 @@ services: - SECRET= - ACCOUNTS_URL=https://huly.your-company.com - HULY_TOKEN= # or HULY_EMAIL + HULY_PASSWORD - - HULY_WORKSPACE= # optional pin + - HULY_WORKSPACE= # required unless the token is workspace-bound + - COLLABORATOR_URL=http://collaborator:3078 # optional, enables issue descriptions ports: - 4090:4090 restart: unless-stopped @@ -49,6 +50,11 @@ Create the API token in Huly under **Settings → API Tokens**. The token carrie the full rights of the account that created it, so use a dedicated service account rather than your own. +The pod must be able to reach the transactor at the address the account service +advertises for the workspace (`TRANSACTOR_URL` on the account service, second +value), and the collaborator at `COLLABORATOR_URL`. In a compose network that +usually means the public host name plus `extra_hosts`, as above. + ### Multi-tenant Leave the credential out and callers present their own Huly API token: @@ -69,12 +75,14 @@ sessions are pinned to the account that opened them. | --- | --- | --- | | `PORT` | `4090` | HTTP listen port | | `HOST` | `0.0.0.0` | Bind address | -| `SECRET` | — | **Required.** Shared Huly signing secret. The pod refuses to start on the default. | +| `SECRET` | — | **Required.** The platform's signing secret (the same value the account and transactor use). The pod refuses to start on the well-known default `secret`. | +| `MCP_ALLOW_DEFAULT_SECRET` | `false` | Lets a throwaway development stack keep `SECRET=secret`. Never set in production. | | `ACCOUNTS_URL` | `http://huly.local:3000` | Account service URL; the public Huly base URL in a self-hosted install | | `SERVICE_ID` | `mcp` | Service name used in logs and metrics | | `HULY_TOKEN` | — | Static Huly API token. Presence selects self-hosted mode. | | `HULY_EMAIL` / `HULY_PASSWORD` | — | Static login, used when no token is set | -| `HULY_WORKSPACE` | — | Pin the configured account to one workspace | +| `HULY_WORKSPACE` | — | Workspace url slug or id. Required with email/password or an account-level token: the pod asks the account service for a token scoped to it. | +| `COLLABORATOR_URL` | — | Collaborator service URL. Without it, tools that write rich text (issue `description`) refuse instead of silently dropping the text; comments and everything else are unaffected. | | `MCP_READONLY` | `false` | Refuse every write tool regardless of credentials | | `MCP_ALLOWED_TOKENS` | — | Comma-separated allowlist for multi-tenant mode | | `MCP_SESSION_TTL_MS` | `1800000` | Idle session lifetime | diff --git a/pods/mcp/package.json b/pods/mcp/package.json index 5143b6628c..29a1bbffb8 100644 --- a/pods/mcp/package.json +++ b/pods/mcp/package.json @@ -61,6 +61,7 @@ "@hcengineering/analytics-service": "workspace:^0.7.19", "@hcengineering/api-client": "workspace:^0.7.25", "@hcengineering/chunter": "workspace:^0.7.0", + "@hcengineering/collaborator-client": "workspace:^0.7.18", "@hcengineering/contact": "workspace:^0.7.0", "@hcengineering/core": "workspace:^0.7.26", "@hcengineering/document": "workspace:^0.7.0", @@ -69,6 +70,8 @@ "@hcengineering/server-core": "workspace:^0.7.19", "@hcengineering/server-token": "workspace:^0.7.18", "@hcengineering/task": "workspace:^0.7.0", + "@hcengineering/text-core": "workspace:^0.7.19", + "@hcengineering/text-markdown": "workspace:^0.7.21", "@hcengineering/tracker": "workspace:^0.7.0", "cors": "^2.8.5", "dotenv": "^16.4.5", diff --git a/pods/mcp/src/__tests__/config.test.ts b/pods/mcp/src/__tests__/config.test.ts new file mode 100644 index 0000000000..425648536e --- /dev/null +++ b/pods/mcp/src/__tests__/config.test.ts @@ -0,0 +1,38 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { loadConfig } from '../config' +import { fakeEnv } from './test-doubles' + +describe('loadConfig secret handling', () => { + it('refuses to start without a secret', () => { + expect(() => loadConfig(fakeEnv())).toThrow(/SECRET must be set/) + }) + + it('refuses the well-known default secret', () => { + expect(() => loadConfig(fakeEnv({ SECRET: 'secret' }))).toThrow(/well-known default/) + }) + + it('accepts the default secret only when a dev stack opts in explicitly', () => { + const config = loadConfig(fakeEnv({ SECRET: 'secret', MCP_ALLOW_DEFAULT_SECRET: 'true' })) + expect(config.Secret).toBe('secret') + }) + + it('accepts a real secret and reads the collaborator url', () => { + const config = loadConfig(fakeEnv({ SECRET: 'a-real-one', COLLABORATOR_URL: 'http://collab:3078' })) + expect(config.CollaboratorUrl).toBe('http://collab:3078') + expect(config.AllowDefaultSecret).toBe(false) + }) +}) diff --git a/pods/mcp/src/auth/__tests__/configured-authenticator.test.ts b/pods/mcp/src/auth/__tests__/configured-authenticator.test.ts new file mode 100644 index 0000000000..02316ba832 --- /dev/null +++ b/pods/mcp/src/auth/__tests__/configured-authenticator.test.ts @@ -0,0 +1,140 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type AccountClient } from '@hcengineering/account-client' +import { type AccountUuid, type PersonUuid, type WorkspaceUuid } from '@hcengineering/core' +import { generateToken } from '@hcengineering/server-token' + +import { fakeMeasureContext } from '../../__tests__/test-doubles' +import { loadConfig } from '../../config' +import { ConfiguredAuthenticator } from '../configured-authenticator' + +const ACCOUNT = '11111111-1111-4111-8111-111111111111' +const WORKSPACE = '22222222-2222-4222-8222-222222222222' +const workspaceToken = generateToken(ACCOUNT as PersonUuid, WORKSPACE as WorkspaceUuid) + +const accountLevelInfo = { account: ACCOUNT as AccountUuid, token: 'account-level-token' } +const workspaceInfo = { + account: ACCOUNT as AccountUuid, + workspace: WORKSPACE as WorkspaceUuid, + workspaceUrl: 'my-space', + endpoint: 'ws://localhost:3333', + token: workspaceToken, + role: 1 +} + +interface Calls { + selectWorkspace: string[] + createdWith: Array +} + +function fakeClientFactory ( + overrides: Partial Promise>>, + calls: Calls +): (url: string, token?: string) => AccountClient { + return (_url, token) => { + calls.createdWith.push(token) + const client = { + getLoginInfoByToken: overrides.getLoginInfoByToken ?? (async () => accountLevelInfo), + login: overrides.login ?? (async () => accountLevelInfo), + selectWorkspace: async (workspaceUrl: string) => { + calls.selectWorkspace.push(workspaceUrl) + return workspaceInfo + } + } + return client as unknown as AccountClient + } +} + +const config = (env: Record): ReturnType => + loadConfig({ SECRET: 'not-the-default', ...env }) + +describe('ConfiguredAuthenticator', () => { + it('exchanges an account-level HULY_TOKEN for a workspace token', async () => { + const calls: Calls = { selectWorkspace: [], createdWith: [] } + const auth = new ConfiguredAuthenticator( + fakeMeasureContext(), + config({ HULY_TOKEN: 'static', HULY_WORKSPACE: 'my-space' }), + fakeClientFactory({}, calls) + ) + + const identity = await auth.authenticate() + + expect(calls.selectWorkspace).toEqual(['my-space']) + expect(calls.createdWith).toEqual(['static', 'static']) + expect(identity.workspace).toBe(WORKSPACE) + expect(identity.workspaceToken).toBe(workspaceToken) + expect(identity.transactorUrl).toBe('http://localhost:3333') + }) + + it('logs in with email and password, then selects the workspace using the login token', async () => { + const calls: Calls = { selectWorkspace: [], createdWith: [] } + const auth = new ConfiguredAuthenticator( + fakeMeasureContext(), + config({ HULY_EMAIL: 'a@example.test', HULY_PASSWORD: 'pw', HULY_WORKSPACE: 'my-space' }), + fakeClientFactory({}, calls) + ) + + await auth.authenticate() + + expect(calls.createdWith).toEqual([undefined, 'account-level-token']) + expect(calls.selectWorkspace).toEqual(['my-space']) + }) + + it('uses a workspace-scoped token as is, without selecting again', async () => { + const calls: Calls = { selectWorkspace: [], createdWith: [] } + const auth = new ConfiguredAuthenticator( + fakeMeasureContext(), + config({ HULY_TOKEN: 'static', HULY_WORKSPACE: 'my-space' }), + fakeClientFactory({ getLoginInfoByToken: async () => workspaceInfo }, calls) + ) + + await auth.authenticate() + + expect(calls.selectWorkspace).toEqual([]) + }) + + it('explains that HULY_WORKSPACE is needed when the token is not workspace-bound', async () => { + const calls: Calls = { selectWorkspace: [], createdWith: [] } + const auth = new ConfiguredAuthenticator(fakeMeasureContext(), config({ HULY_TOKEN: 'static' }), fakeClientFactory({}, calls)) + + await expect(auth.authenticate()).rejects.toThrow(/HULY_WORKSPACE/) + }) + + it('rejects a login that returns no token, such as one that needs two-factor', async () => { + const calls: Calls = { selectWorkspace: [], createdWith: [] } + const auth = new ConfiguredAuthenticator( + fakeMeasureContext(), + config({ HULY_EMAIL: 'a@example.test', HULY_PASSWORD: 'pw', HULY_WORKSPACE: 'my-space' }), + fakeClientFactory({ login: async () => ({ account: ACCOUNT, tfaRequired: true }) }, calls) + ) + + await expect(auth.authenticate()).rejects.toThrow(/rejected/) + }) + + it('caches the resolved identity', async () => { + const calls: Calls = { selectWorkspace: [], createdWith: [] } + const auth = new ConfiguredAuthenticator( + fakeMeasureContext(), + config({ HULY_TOKEN: 'static', HULY_WORKSPACE: 'my-space' }), + fakeClientFactory({}, calls) + ) + + await auth.authenticate() + await auth.authenticate() + + expect(calls.selectWorkspace).toHaveLength(1) + }) +}) diff --git a/pods/mcp/src/auth/configured-authenticator.ts b/pods/mcp/src/auth/configured-authenticator.ts index 84ed506203..bd93d72a42 100644 --- a/pods/mcp/src/auth/configured-authenticator.ts +++ b/pods/mcp/src/auth/configured-authenticator.ts @@ -25,6 +25,9 @@ import { decodeToken } from '@hcengineering/server-token' import { type Config } from '../config' import { AuthenticationError, type Authenticator, type SessionIdentity, toTransactorHttpUrl } from './authenticator' +/** Builds an account-service client; injectable so the login flow can be unit tested. */ +export type AccountClientFactory = (accountsUrl: string, token?: string) => AccountClient + /** * Authenticates every caller as one account configured on the pod itself. * @@ -43,9 +46,12 @@ export class ConfiguredAuthenticator implements Authenticator { private cached: { identity: SessionIdentity, expiresOn: number } | undefined private inFlight: Promise | undefined - constructor (ctx: MeasureContext, config: Config) { + private readonly createClient: AccountClientFactory + + constructor (ctx: MeasureContext, config: Config, createClient: AccountClientFactory = getAccountClient) { this.ctx = ctx this.config = config + this.createClient = createClient } async authenticate (): Promise { @@ -81,12 +87,13 @@ export class ConfiguredAuthenticator implements Authenticator { private async login (): Promise { try { - if (this.config.HulyToken !== '') { - const client = getAccountClient(this.config.AccountsUrl, this.config.HulyToken) - return await client.getLoginInfoByToken() + const { info, accountToken } = await this.loginAtAccountLevel() + if (isWorkspaceLoginInfo(info) || this.config.HulyWorkspace === '') { + return info } - const client: AccountClient = getAccountClient(this.config.AccountsUrl) - return await client.login(this.config.HulyEmail, this.config.HulyPassword) + // A login or a plain account token is scoped to the account, not to a + // workspace, so the account service must be asked for a workspace token. + return await this.createClient(this.config.AccountsUrl, accountToken).selectWorkspace(this.config.HulyWorkspace) } catch (err) { this.ctx.error('mcp configured login failed', { error: (err as Error)?.message }) throw new AuthenticationError( @@ -96,6 +103,19 @@ export class ConfiguredAuthenticator implements Authenticator { } } + private async loginAtAccountLevel (): Promise<{ info: LoginInfoByToken, accountToken: string }> { + if (this.config.HulyToken !== '') { + const client = this.createClient(this.config.AccountsUrl, this.config.HulyToken) + return { info: await client.getLoginInfoByToken(), accountToken: this.config.HulyToken } + } + const client = this.createClient(this.config.AccountsUrl) + const info = await client.login(this.config.HulyEmail, this.config.HulyPassword) + if (info?.token === undefined) { + throw new Error('The account service returned no token (two-factor authentication may be required)') + } + return { info, accountToken: info.token } + } + private toIdentity (loginInfo: LoginInfoByToken): SessionIdentity { if (!isWorkspaceLoginInfo(loginInfo)) { throw new AuthenticationError( diff --git a/pods/mcp/src/config.ts b/pods/mcp/src/config.ts index c2cfa515a0..eda5aed5ce 100644 --- a/pods/mcp/src/config.ts +++ b/pods/mcp/src/config.ts @@ -45,6 +45,10 @@ export interface Config { HulyPassword: string /** Restrict `configured` mode to one workspace, by id or url slug. */ HulyWorkspace: string + /** Collaborator service URL; enables writing rich-text descriptions. */ + CollaboratorUrl: string + /** Lets a development stack keep the platform-wide default secret. Never enable in production. */ + AllowDefaultSecret: boolean /** Drop write tools and refuse all mutations. */ ReadOnly: boolean /** Identifiers callers may present when `AuthMode` is `perRequest`. */ @@ -112,6 +116,8 @@ function buildConfig (env: NodeJS.ProcessEnv): Config { HulyEmail: env.HULY_EMAIL ?? '', HulyPassword: env.HULY_PASSWORD ?? '', HulyWorkspace: env.HULY_WORKSPACE ?? '', + CollaboratorUrl: env.COLLABORATOR_URL ?? '', + AllowDefaultSecret: bool(env.MCP_ALLOW_DEFAULT_SECRET, false), ReadOnly: bool(env.MCP_READONLY, false), AllowedTokens: list(env.MCP_ALLOWED_TOKENS), SessionIdleTtlMs: int(env.MCP_SESSION_TTL_MS, 30 * 60_000), @@ -133,8 +139,14 @@ function buildConfig (env: NodeJS.ProcessEnv): Config { * only safe response. */ function validate (config: Config): Config { - if (config.Secret === '' || config.Secret === 'secret') { - throw Error('SECRET must be set to a real secret; refusing to start with the default') + if (config.Secret === '') { + throw Error('SECRET must be set; refusing to start without it') + } + if (config.Secret === 'secret' && !config.AllowDefaultSecret) { + throw Error( + 'SECRET is the well-known default; refusing to start. Use the platform secret, ' + + 'or set MCP_ALLOW_DEFAULT_SECRET=true for a throwaway development stack only' + ) } if (config.Port < 1 || config.Port > 65535) { throw Error(`PORT is out of range: ${config.Port}`) diff --git a/pods/mcp/src/index.ts b/pods/mcp/src/index.ts index a7af44f447..927ccf8890 100644 --- a/pods/mcp/src/index.ts +++ b/pods/mcp/src/index.ts @@ -24,6 +24,7 @@ import { join } from 'node:path' import { createAuthenticator } from './auth/authenticator-factory' import { loadConfig } from './config' import { RateLimiter } from './middleware/rate-limiter' +import { createCollaboratorWriter } from './platform/collaborator-writer' import { CachingWorkspaceClientProvider } from './platform/workspace-client-provider' import { createServer, listen } from './server' import { buildRegistry } from './tools/register' @@ -62,7 +63,11 @@ async function main (): Promise { const registry = buildRegistry() const authenticator = createAuthenticator(ctx, config) - const clients = new CachingWorkspaceClientProvider({ ctx, idleTtlMs: config.ClientCacheTtlMs }) + const clients = new CachingWorkspaceClientProvider({ + ctx, + idleTtlMs: config.ClientCacheTtlMs, + createMarkupWriter: createCollaboratorWriter(config.CollaboratorUrl) + }) const limiter = new RateLimiter(ctx, config.RequestRateLimit, config.RequestRateWindowMs) const { app, sessions, transport } = createServer({ diff --git a/pods/mcp/src/mcp/__tests__/registry-and-session.test.ts b/pods/mcp/src/mcp/__tests__/registry-and-session.test.ts index 841f22bc15..6157d3858a 100644 --- a/pods/mcp/src/mcp/__tests__/registry-and-session.test.ts +++ b/pods/mcp/src/mcp/__tests__/registry-and-session.test.ts @@ -66,6 +66,13 @@ describe('ToolRegistry', () => { expect(result.content[0].text).toContain('must be of type string') }) + it('hides write tools from a read-only listing', () => { + const registry = new ToolRegistry().registerAll([echoTool, writeTool]) + + expect(registry.list({ readOnly: true }).map((tool) => tool.name)).toEqual([echoTool.name]) + expect(registry.list().map((tool) => tool.name)).toEqual([echoTool.name, writeTool.name]) + }) + it('blocks a write tool for a read-only identity', async () => { const registry = new ToolRegistry().registerAll([echoTool, writeTool]) const identity = fakeIdentity({ readOnly: true }) diff --git a/pods/mcp/src/mcp/dispatcher.ts b/pods/mcp/src/mcp/dispatcher.ts index 5f3840dc43..7eabd1d24f 100644 --- a/pods/mcp/src/mcp/dispatcher.ts +++ b/pods/mcp/src/mcp/dispatcher.ts @@ -137,7 +137,7 @@ export class McpDispatcher { case 'ping': return {} case 'tools/list': - return { tools: this.registry.list() } + return { tools: this.registry.list({ readOnly: session.identity.readOnly }) } case 'tools/call': return await this.callTool(session, request.params) case 'resources/list': diff --git a/pods/mcp/src/mcp/tool.ts b/pods/mcp/src/mcp/tool.ts index d4fc135617..63a1af3737 100644 --- a/pods/mcp/src/mcp/tool.ts +++ b/pods/mcp/src/mcp/tool.ts @@ -88,8 +88,10 @@ export class ToolRegistry { return this.tools.size } - list (): McpToolDescriptor[] { - return [...this.tools.values()].map((tool) => ({ + /** Lists tools; a read-only caller is not shown tools it could never run. */ + list (options: { readOnly?: boolean } = {}): McpToolDescriptor[] { + const visible = [...this.tools.values()].filter((tool) => options.readOnly !== true || tool.readOnly) + return visible.map((tool) => ({ name: tool.name, title: tool.title, description: tool.description, diff --git a/pods/mcp/src/platform/__tests__/markup.test.ts b/pods/mcp/src/platform/__tests__/markup.test.ts new file mode 100644 index 0000000000..9ff7785b3b --- /dev/null +++ b/pods/mcp/src/platform/__tests__/markup.test.ts @@ -0,0 +1,41 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { fromMarkup, toMarkup } from '../markup' + +describe('markup conversion', () => { + it('round-trips Markdown through the stored rich-text format', () => { + const stored = toMarkup('# Title\n\nSome **bold** text\n\n- one\n- two') + + expect(stored).toContain('"type":"doc"') + const back = fromMarkup(stored) + expect(back).toContain('# Title') + expect(back).toContain('**bold**') + expect(back).toContain('one') + }) + + it('never returns ProseMirror JSON to the agent', () => { + expect(fromMarkup(toMarkup('plain text'))).not.toContain('"type"') + }) + + it('returns non-markup text unchanged instead of throwing', () => { + expect(fromMarkup('just a legacy string')).toBe('just a legacy string') + }) + + it('treats an empty blob as an empty description', () => { + expect(fromMarkup('')).toBe('') + expect(fromMarkup(' ')).toBe('') + }) +}) diff --git a/pods/mcp/src/platform/collaborator-writer.ts b/pods/mcp/src/platform/collaborator-writer.ts new file mode 100644 index 0000000000..59267d8eac --- /dev/null +++ b/pods/mcp/src/platform/collaborator-writer.ts @@ -0,0 +1,43 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { getClient as getCollaboratorClient } from '@hcengineering/collaborator-client' +import { makeCollabId, type Class, type Doc, type Ref } from '@hcengineering/core' + +import { type SessionIdentity } from '../auth/authenticator' +import { toMarkup } from './markup' +import { type MarkupWriter } from './markup-reader' + +/** + * Builds a writer that stores rich text through the collaborator service. + * + * Returns undefined when no collaborator URL is configured, so tools can refuse + * a description explicitly instead of dropping it. + */ +export function createCollaboratorWriter ( + collaboratorUrl: string +): ((identity: SessionIdentity) => MarkupWriter | undefined) { + if (collaboratorUrl === '') return () => undefined + + return (identity) => { + const client = getCollaboratorClient(identity.workspace, identity.workspaceToken, collaboratorUrl) + return { + write: async (objectClass, objectId, attribute, markdown) => { + const collabId = makeCollabId(objectClass as Ref>, objectId as Ref, attribute) + return await client.createMarkup(collabId, toMarkup(markdown)) + } + } + } +} diff --git a/pods/mcp/src/platform/markup-reader.ts b/pods/mcp/src/platform/markup-reader.ts index 0daa4c1b68..4e474670e1 100644 --- a/pods/mcp/src/platform/markup-reader.ts +++ b/pods/mcp/src/platform/markup-reader.ts @@ -23,6 +23,15 @@ export interface MarkupReader { read: (ref: string) => Promise } +/** + * Stores rich text in the workspace and returns the blob reference to keep on + * the owning document. Writing goes through the collaborator service, which is + * optional infrastructure, so a session may have no writer at all. + */ +export interface MarkupWriter { + write: (objectClass: string, objectId: string, attribute: string, markdown: string) => Promise +} + /** Reads a bounded number of characters, appending a marker when it truncates. */ export function truncate (value: string, maxChars: number): string { if (value.length <= maxChars) return value diff --git a/pods/mcp/src/platform/markup.ts b/pods/mcp/src/platform/markup.ts new file mode 100644 index 0000000000..29d55eb456 --- /dev/null +++ b/pods/mcp/src/platform/markup.ts @@ -0,0 +1,39 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type Markup } from '@hcengineering/core' +import { jsonToMarkup, markupToJSON } from '@hcengineering/text-core' +import { markdownToMarkup, markupToMarkdown } from '@hcengineering/text-markdown' + +/** Converts agent-supplied Markdown (or plain text) into Huly's stored rich-text format. */ +export function toMarkup (markdown: string): Markup { + return jsonToMarkup(markdownToMarkup(markdown)) +} + +/** + * Renders stored rich text as Markdown for an agent. + * + * Huly stores rich text as ProseMirror JSON, which is noisy and wastes tokens. + * Anything that is not valid markup (legacy plain text, an empty blob) is + * returned unchanged instead of failing the tool call. + */ +export function fromMarkup (markup: string): string { + if (markup.trim() === '') return '' + try { + return markupToMarkdown(markupToJSON(markup)) + } catch { + return markup + } +} diff --git a/pods/mcp/src/platform/workspace-client-provider.ts b/pods/mcp/src/platform/workspace-client-provider.ts index 60a3f04c1d..0add8a7a53 100644 --- a/pods/mcp/src/platform/workspace-client-provider.ts +++ b/pods/mcp/src/platform/workspace-client-provider.ts @@ -17,13 +17,16 @@ import { createRestTxOperations } from '@hcengineering/api-client' import { type AccountUuid, type MeasureContext, type TxOperations, type WorkspaceUuid } from '@hcengineering/core' import { type SessionIdentity } from '../auth/authenticator' -import { type MarkupReader } from './markup-reader' +import { type MarkupReader, type MarkupWriter } from './markup-reader' +import { fromMarkup } from './markup' /** Everything a tool needs to talk to one workspace on behalf of one user. */ export interface WorkspaceSession { client: TxOperations identity: SessionIdentity markup: MarkupReader + /** Undefined when no collaborator service is configured. */ + markupWriter?: MarkupWriter } export interface WorkspaceClientProvider { @@ -38,6 +41,7 @@ export interface WorkspaceClientProviderOptions { ctx: MeasureContext createClient?: ClientFactory createMarkupReader?: (identity: SessionIdentity) => MarkupReader + createMarkupWriter?: (identity: SessionIdentity) => MarkupWriter | undefined /** Idle time after which a cached client is closed, in milliseconds. */ idleTtlMs?: number /** How often idle entries are swept, in milliseconds. */ @@ -72,6 +76,7 @@ export class CachingWorkspaceClientProvider implements WorkspaceClientProvider { private readonly ctx: MeasureContext private readonly createClient: ClientFactory private readonly createMarkupReader: (identity: SessionIdentity) => MarkupReader + private readonly createMarkupWriter: (identity: SessionIdentity) => MarkupWriter | undefined private readonly idleTtlMs: number private readonly now: () => number private readonly cache = new Map() @@ -82,6 +87,7 @@ export class CachingWorkspaceClientProvider implements WorkspaceClientProvider { this.ctx = options.ctx this.createClient = options.createClient ?? defaultClientFactory this.createMarkupReader = options.createMarkupReader ?? defaultMarkupReaderFactory + this.createMarkupWriter = options.createMarkupWriter ?? (() => undefined) this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS this.now = options.now ?? Date.now @@ -111,7 +117,8 @@ export class CachingWorkspaceClientProvider implements WorkspaceClientProvider { const session: WorkspaceSession = { client, identity, - markup: this.createMarkupReader(identity) + markup: this.createMarkupReader(identity), + markupWriter: this.createMarkupWriter(identity) } this.cache.set(key, { session, lastUsed: this.now() }) return session @@ -163,7 +170,7 @@ const defaultClientFactory: ClientFactory = async (identity) => await createRestTxOperations(identity.transactorUrl, identity.workspace, identity.workspaceToken, true) const defaultMarkupReaderFactory = (identity: SessionIdentity): MarkupReader => ({ - read: async (ref: string) => await fetchMarkup(identity.transactorUrl, identity.workspaceToken, ref) + read: async (ref: string) => fromMarkup(await fetchMarkup(identity.transactorUrl, identity.workspaceToken, ref)) }) /** diff --git a/pods/mcp/src/tools/__tests__/tools.test.ts b/pods/mcp/src/tools/__tests__/tools.test.ts new file mode 100644 index 0000000000..043231580b --- /dev/null +++ b/pods/mcp/src/tools/__tests__/tools.test.ts @@ -0,0 +1,275 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import core, { type TxOperations } from '@hcengineering/core' +import chunter from '@hcengineering/chunter' +import contact from '@hcengineering/contact' +import task from '@hcengineering/task' +import tracker from '@hcengineering/tracker' + +import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' +import { toolContext, type ToolContext } from '../../mcp/tool' +import { type MarkupWriter } from '../../platform/markup-reader' +import { type WorkspaceSession } from '../../platform/workspace-client-provider' +import { addCommentTool, createIssueTool, listIssuesTool } from '../issue-tools' +import { findPeopleTool } from '../people-tools' +import { listProjectsTool } from '../project-tools' +import { likePattern, projectNames } from '../shared' + +type Row = Record + +interface Recorded { + findAll: Array<{ cls: string, query: Row }> + updateDoc: Row[] + addCollection: Array<{ cls: string, space: string, attachedTo: string, collection: string, attributes: Row }> +} + +interface Script { + findAll?: Record Row[])> + findOne?: Record + sequence?: number +} + +function scriptedClient (script: Script): { client: TxOperations, recorded: Recorded } { + const recorded: Recorded = { findAll: [], updateDoc: [], addCollection: [] } + const client = { + findAll: async (cls: string, query: Row) => { + recorded.findAll.push({ cls, query }) + const rows = script.findAll?.[cls] + return typeof rows === 'function' ? rows(query) : (rows ?? []) + }, + findOne: async (cls: string) => script.findOne?.[cls], + updateDoc: async (...args: unknown[]) => { + recorded.updateDoc.push({ args }) + return { object: { sequence: script.sequence ?? 1 } } + }, + addCollection: async (cls: string, space: string, attachedTo: string, _attachedToClass: string, collection: string, attributes: Row) => { + recorded.addCollection.push({ cls, space, attachedTo, collection, attributes }) + } + } + return { client: client as unknown as TxOperations, recorded } +} + +function context (client: TxOperations, markupWriter?: MarkupWriter): ToolContext { + const session: WorkspaceSession = { + client, + identity: fakeIdentity(), + markup: { read: async () => '' }, + markupWriter + } + return toolContext(session, fakeMeasureContext()) +} + +const PROJECT = { _id: 'proj-1', identifier: 'HULY', type: 'type-1', defaultIssueStatus: null } +const TASK_TYPE = { _id: 'kind-1', statuses: ['status-backlog', 'status-todo'] } + +describe('likePattern', () => { + it('wraps the needle and escapes LIKE wildcards', () => { + expect(likePattern('abc')).toBe('%abc%') + expect(likePattern('50%_off')).toBe('%50\\%\\_off%') + }) +}) + +describe('huly_create_issue', () => { + const script = (): Script => ({ + findOne: { [tracker.class.Project]: PROJECT, [task.class.TaskType]: TASK_TYPE }, + sequence: 7 + }) + + it('creates through addCollection, numbers via the atomic sequence and falls back to the task type status', async () => { + const { client, recorded } = scriptedClient(script()) + + const result = await createIssueTool.handler(context(client), { projectId: 'proj-1', title: 'Hello' }) + + expect(recorded.updateDoc).toHaveLength(1) + expect(recorded.addCollection).toHaveLength(1) + const call = recorded.addCollection[0] + expect(call.cls).toBe(tracker.class.Issue) + expect(call.space).toBe('proj-1') + expect(call.collection).toBe('subIssues') + expect(call.attributes).toMatchObject({ + number: 7, + identifier: 'HULY-7', + kind: 'kind-1', + status: 'status-backlog', + description: null + }) + expect(result.content[0]).toMatchObject({ type: 'text' }) + expect(JSON.stringify(result.content)).toContain('HULY-7') + }) + + it('prefers an explicit status, then the project default', async () => { + const withDefault = scriptedClient({ + ...script(), + findOne: { [tracker.class.Project]: { ...PROJECT, defaultIssueStatus: 'status-todo' }, [task.class.TaskType]: TASK_TYPE } + }) + await createIssueTool.handler(context(withDefault.client), { projectId: 'proj-1', title: 'A' }) + expect(withDefault.recorded.addCollection[0].attributes.status).toBe('status-todo') + + const explicit = scriptedClient(script()) + await createIssueTool.handler(context(explicit.client), { projectId: 'proj-1', title: 'B', statusId: 'status-x' }) + expect(explicit.recorded.addCollection[0].attributes.status).toBe('status-x') + }) + + it('refuses a description without a markup writer and consumes no issue number', async () => { + const { client, recorded } = scriptedClient(script()) + + const result = await createIssueTool.handler(context(client), { + projectId: 'proj-1', + title: 'Hello', + description: 'Body' + }) + + expect(JSON.stringify(result.content)).toContain('COLLABORATOR_URL') + expect(recorded.updateDoc).toHaveLength(0) + expect(recorded.addCollection).toHaveLength(0) + }) + + it('stores the description through the writer and keeps its reference', async () => { + const { client, recorded } = scriptedClient(script()) + const written: string[][] = [] + const writer: MarkupWriter = { + write: async (cls, id, attribute, markdown) => { + written.push([cls, attribute, markdown]) + return `blob-for-${id}` + } + } + + await createIssueTool.handler(context(client, writer), { projectId: 'proj-1', title: 'Hello', description: 'Body' }) + + expect(written).toEqual([[tracker.class.Issue, 'description', 'Body']]) + expect(String(recorded.addCollection[0].attributes.description)).toMatch(/^blob-for-/) + }) + + it('does not create anything for an unknown project', async () => { + const { client, recorded } = scriptedClient({}) + await createIssueTool.handler(context(client), { projectId: 'missing', title: 'Hello' }) + expect(recorded.addCollection).toHaveLength(0) + }) +}) + +describe('huly_add_issue_comment', () => { + it('attaches the comment to the issue as rich text instead of creating a free-standing doc', async () => { + const { client, recorded } = scriptedClient({ + findOne: { [tracker.class.Issue]: { _id: 'issue-1', space: 'proj-1' } } + }) + + await addCommentTool.handler(context(client), { issueId: 'issue-1', text: 'A **bold** note' }) + + expect(recorded.addCollection).toHaveLength(1) + const call = recorded.addCollection[0] + expect(call.cls).toBe(chunter.class.ChatMessage) + expect(call.space).toBe('proj-1') + expect(call.attachedTo).toBe('issue-1') + expect(call.collection).toBe('comments') + expect(String(call.attributes.message)).toContain('"type":"doc"') + }) +}) + +describe('huly_list_issues', () => { + const statuses = [ + { _id: 's-todo', name: 'Todo' }, + { _id: 's-done', name: 'Done' }, + { _id: 's-lost', name: 'Canceled' } + ] + + function scripted (): ReturnType { + return scriptedClient({ + findAll: { + [tracker.class.IssueStatus]: statuses, + [core.class.Status]: statuses, + [tracker.class.Issue]: [] + } + }) + } + + it('searches titles with a database LIKE, not a regular expression', async () => { + const { client, recorded } = scripted() + await listIssuesTool.handler(context(client), { search: 'Smoke' }) + + const issueQuery = recorded.findAll.find((call) => call.cls === tracker.class.Issue)?.query + expect(issueQuery?.title).toEqual({ $like: '%Smoke%' }) + }) + + it('does not apply the search text as a regular expression', async () => { + const { client, recorded } = scripted() + await listIssuesTool.handler(context(client), { search: '(a+)+' }) + + const title = recorded.findAll.find((call) => call.cls === tracker.class.Issue)?.query.title + expect(JSON.stringify(title)).not.toContain('regex') + }) +}) + +describe('project listings', () => { + it('does not list a tracker project a second time as a task project', async () => { + const row = { _id: 'proj-1', name: 'Huly', identifier: 'HULY', archived: false, private: false } + const { client } = scriptedClient({ + findAll: { [tracker.class.Project]: [row], [task.class.Project]: [row, { _id: 'proj-2', name: 'Board' }] } + }) + + const result = await listProjectsTool.handler(context(client), {}) + const payload = JSON.parse((result.content[0] as { text: string }).text) as { projects: Array<{ id: string, kind: string }> } + + expect(payload.projects.map((project) => project.id)).toEqual(['proj-1', 'proj-2']) + expect(payload.projects.map((project) => project.kind)).toEqual(['tracker', 'task']) + }) + + it('keeps the tracker identifier when the same id also comes back as a task project', async () => { + const { client } = scriptedClient({ + findAll: { + [tracker.class.Project]: [{ _id: 'proj-1', name: 'Huly', identifier: 'HULY' }], + [task.class.Project]: [{ _id: 'proj-1', name: 'Huly' }] + } + }) + + const names = await projectNames(client, ['proj-1']) + + expect(names.get('proj-1')?.identifier).toBe('HULY') + }) +}) + +describe('huly_find_people', () => { + it('filters by name and email in the database, before any limit', async () => { + const { client, recorded } = scriptedClient({ + findAll: { + [contact.class.Person]: [{ _id: 'p-1', name: 'Hopper,Grace' }], + [contact.class.SocialIdentity]: [], + [contact.class.Channel]: [] + } + }) + + await findPeopleTool.handler(context(client), { query: 'hopper' }) + + const personQuery = recorded.findAll.find((call) => call.cls === contact.class.Person)?.query + expect(personQuery?.name).toEqual({ $like: '%hopper%' }) + const identityQuery = recorded.findAll.find((call) => call.cls === contact.class.SocialIdentity)?.query + expect(identityQuery?.value).toEqual({ $like: '%hopper%' }) + }) + + it('reads emails from identities attached to the person, not by matching ids', async () => { + const { client } = scriptedClient({ + findAll: { + [contact.class.Person]: [{ _id: 'p-1', name: 'Hopper,Grace' }], + [contact.class.SocialIdentity]: [{ attachedTo: 'p-1', type: 'email', value: 'grace@example.test' }], + [contact.class.Channel]: [] + } + }) + + const result = await findPeopleTool.handler(context(client), {}) + const payload = JSON.parse((result.content[0] as { text: string }).text) as { people: Array<{ email: string | null }> } + + expect(payload.people[0].email).toBe('grace@example.test') + }) +}) diff --git a/pods/mcp/src/tools/document-tools.ts b/pods/mcp/src/tools/document-tools.ts index 98912adfec..e28396e691 100644 --- a/pods/mcp/src/tools/document-tools.ts +++ b/pods/mcp/src/tools/document-tools.ts @@ -20,7 +20,7 @@ import { truncate } from '../platform/markup-reader' import { textResult } from '../mcp/protocol' import { numberProp, objectSchema, stringProp } from '../mcp/schema' import { type HulyTool } from '../mcp/tool' -import { clampLimit, toIso } from './shared' +import { clampLimit, likePattern, toIso } from './shared' /** Bodies can be very large; keep responses inside a model's context window. */ const BODY_CHAR_LIMIT = 20_000 @@ -51,7 +51,7 @@ export const listDocumentsTool: HulyTool = { handler: async (ctx, args) => { const query: Record = {} if (args.spaceId !== undefined) query.space = args.spaceId - if (args.search !== undefined) query.title = { $regex: escapeRegExp(String(args.search)), $options: 'i' } + if (args.search !== undefined) query.title = { $like: likePattern(String(args.search)) } const documents = (await ctx.client.findAll(doc.class.Document, query as never, { limit: clampLimit(args.limit), @@ -134,8 +134,4 @@ export const getDocumentTool: HulyTool = { } } -function escapeRegExp (value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - export const documentTools: HulyTool[] = [listDocumentsTool, getDocumentTool] diff --git a/pods/mcp/src/tools/issue-tools.ts b/pods/mcp/src/tools/issue-tools.ts index c6c3b7a9a2..e54c39c254 100644 --- a/pods/mcp/src/tools/issue-tools.ts +++ b/pods/mcp/src/tools/issue-tools.ts @@ -15,12 +15,14 @@ import chunter, { type ChatMessage } from '@hcengineering/chunter' import core, { generateId, SortingOrder } from '@hcengineering/core' +import task from '@hcengineering/task' import tracker, { IssuePriority, type Issue, type Milestone } from '@hcengineering/tracker' import { textResult } from '../mcp/protocol' +import { toMarkup } from '../platform/markup' import { booleanProp, objectSchema, stringProp } from '../mcp/schema' import { type HulyTool, type ToolContext } from '../mcp/tool' -import { clampLimit, personNames, projectNames, statusNames, toIso, uniqueIds } from './shared' +import { clampLimit, likePattern, personNames, projectNames, statusNames, toIso } from './shared' /** The slice of an Issue the tools read. */ interface IssueRow { @@ -52,8 +54,6 @@ const priorityIndex = (name: string | undefined): number => { return index < 0 ? IssuePriority.NoPriority : index } -const uniqueStatuses = (issues: IssueRow[]): string[] => uniqueIds(issues.map((issue) => issue.status)) - /** * Turns raw issues into the shape an agent can act on: human names instead of * ids, ISO dates instead of epoch millis, and an `identifier-number` key. @@ -96,6 +96,31 @@ async function formatIssues (ctx: ToolContext, issues: IssueRow[]): Promise { + const query: Record = { ofAttribute: tracker.attribute.IssueStatus } + const statuses = (await ctx.client.findAll(tracker.class.IssueStatus, query as never, { + limit: 200 + })) as unknown as Array<{ _id: string }> + const resolved = await statusNames( + ctx.client, + statuses.map((status) => status._id) + ) + + const wanted = name?.toLowerCase() + return statuses + .filter((status) => { + const info = resolved.get(status._id) + if (wanted !== undefined && (info?.name ?? '').toLowerCase() !== wanted) return false + if (excludeDone && DONE_CATEGORIES.has((info?.category ?? '').toLowerCase())) return false + return true + }) + .map((status) => status._id) +} + export const listIssuesTool: HulyTool = { name: 'huly_list_issues', title: 'List issues', @@ -119,36 +144,28 @@ export const listIssuesTool: HulyTool = { if (args.projectId !== undefined) query.space = args.projectId if (args.assignee !== undefined) query.assignee = args.assignee if (args.search !== undefined) { - // Anchored, escaped substring match: an unescaped user string would be - // interpreted as a regular expression by the storage layer. - query.title = { $regex: `^${escapeRegExp(String(args.search))}`, $options: 'i' } + query.title = { $like: likePattern(String(args.search)) } } - let issues = (await ctx.client.findAll(tracker.class.Issue, query as never, { - limit, - sort: { modifiedOn: SortingOrder.Descending } - })) as unknown as IssueRow[] - - // Status filtering happens in JS on purpose: status is a ref to a document, - // so filtering by name would otherwise need a join the storage layer does - // not do. The page limit was already applied above. + // Status is a reference to a status document, so a name or a "done" filter + // is turned into a set of status ids first. Filtering before the limit + // means a page is never emptied by a filter applied afterwards. if (args.status !== undefined || args.includeDone === false) { - const statuses = await statusNames(ctx.client, uniqueStatuses(issues)) - - if (args.status !== undefined) { - const wanted = String(args.status).toLowerCase() - issues = issues.filter((issue) => (statuses.get(issue.status)?.name ?? '').toLowerCase() === wanted) - } - - if (args.includeDone === false) { - issues = issues.filter((issue) => { - const category = statuses.get(issue.status)?.category - // A status with no category cannot be known to be done, so it stays. - return category == null || category.toLowerCase() !== 'done' - }) + const allowed = await allowedStatusIds(ctx, args.status as string | undefined, args.includeDone === false) + if (allowed.length === 0) { + return textResult( + `No issue status matches "${String(args.status)}". Call huly_list_issue_statuses for the valid names.`, + { issues: [] } + ) } + query.status = { $in: allowed } } + const issues = (await ctx.client.findAll(tracker.class.Issue, query as never, { + limit, + sort: { modifiedOn: SortingOrder.Descending } + })) as unknown as IssueRow[] + if (issues.length === 0) { return textResult('No issues matched. Try widening the filters or call huly_list_projects.', { issues: [] @@ -301,54 +318,106 @@ export const createIssueTool: HulyTool = { }) } - const statusId = (args.statusId as string | undefined) ?? project.defaultIssueStatus - if (statusId === undefined) { + // The task type decides both the issue `kind` and, when the project itself + // names no default, the first status of the workflow. + const taskTypeQuery: Record = { parent: project.type, ofClass: tracker.class.Issue } + const taskType = (await ctx.client.findOne(task.class.TaskType, taskTypeQuery as never)) as unknown as + | TaskTypeDefaults + | undefined + + const statusId = (args.statusId as string | undefined) ?? project.defaultIssueStatus ?? taskType?.statuses?.[0] + if (statusId === undefined || taskType === undefined) { return textResult( - 'The project has no default issue status configured, so the issue was not created. ' + - 'Pass an explicit statusId, or ask an administrator to set a default status on the project.', + 'The project has no default issue status or issue task type configured, so the issue was not created. ' + + 'Pass an explicit statusId, or ask an administrator to configure the project.', { created: false } ) } - // Numbers are assigned per project. Huly's numbering middleware lives - // outside this repository, so the next number is derived here; a concurrent - // create could claim the same value. - const highestQuery: Record = { space: projectId } - const highest = (await ctx.client.findAll(tracker.class.Issue, highestQuery as never, { - limit: 1, - sort: { number: SortingOrder.Descending }, - projection: { number: 1 } - })) as unknown as Array<{ number: number }> - const number = (highest[0]?.number ?? 0) + 1 - + // The body is stored as a blob owned by the collaborator service. Refuse up + // front, before a number is consumed, when that service is not configured. const issueId = generateId() + const description = ((args.description as string | undefined) ?? '').trim() + let descriptionRef: string | null = null + if (description !== '') { + if (ctx.markupWriter === undefined) { + return textResult( + 'A description was given but this server has no COLLABORATOR_URL configured, so the issue was not ' + + 'created. Retry without a description, or ask the administrator to set COLLABORATOR_URL.', + { created: false } + ) + } + descriptionRef = await ctx.markupWriter.write(tracker.class.Issue, issueId, 'description', description) + } + + // Numbers are assigned by incrementing the project's sequence counter, which + // the transactor applies atomically. This is what the web client does, so + // concurrent creates never receive the same number. + const increment: Record = { $inc: { sequence: 1 } } + const incremented = (await ctx.client.updateDoc( + tracker.class.Project, + core.space.Space, + projectId as never, + increment as never, + true + )) as unknown as { object?: { sequence?: number } } + const number = incremented.object?.sequence + if (typeof number !== 'number') { + throw new Error('The workspace did not return the next issue number') + } + const identifier = `${project.identifier ?? '?'}-${number}` + const attributes: Record = { - number, title: args.title, + description: descriptionRef, + assignee: (args.assignee as string | undefined) ?? null, + component: null, + milestone: (args.milestone as string | undefined) ?? null, + number, + identifier, + kind: taskType._id, status: statusId, priority: priorityIndex(args.priority as string | undefined), - assignee: (args.assignee as string | undefined) ?? null, - space: projectId, + rank: '', + comments: 0, + subIssues: 0, + parents: [], + childInfo: [], + relations: [], startDate: toTimestamp(args.startDate as string | undefined), dueDate: toTimestamp(args.dueDate as string | undefined), estimation: 0, remainingTime: 0, reportedTime: 0, - milestone: (args.milestone as string | undefined) ?? null + reports: 0 } - await ctx.client.createDoc(tracker.class.Issue, projectId as never, attributes as never, issueId) - - return textResult( - JSON.stringify({ id: issueId, key: `${project.identifier ?? '?'}-${number}`, title: args.title }, null, 2), - { created: true, issueId } + await ctx.client.addCollection( + tracker.class.Issue, + projectId as never, + tracker.ids.NoParent, + tracker.class.Issue, + 'subIssues', + attributes as never, + issueId ) + + return textResult(JSON.stringify({ id: issueId, key: identifier, title: args.title }, null, 2), { + created: true, + issueId + }) } } interface ProjectDefaults { identifier?: string - defaultIssueStatus?: string + type?: string + defaultIssueStatus?: string | null +} + +interface TaskTypeDefaults { + _id: string + statuses?: string[] } export const updateIssueTool: HulyTool = { @@ -439,13 +508,19 @@ export const addCommentTool: HulyTool = { }) } + // Comments are attached documents living in the issue's own space, and the + // message body is stored as rich text, not as the raw string the agent sent. const messageId = generateId() - const attributes: Record = { - attachedTo: issueId, - collection: 'comments', - message: args.text - } - await ctx.client.createDoc(chunter.class.ChatMessage, core.space.Space, attributes as never, messageId) + const attributes: Record = { message: toMarkup(args.text as string) } + await ctx.client.addCollection( + chunter.class.ChatMessage, + issue.space as never, + issueId as never, + tracker.class.Issue, + 'comments', + attributes as never, + messageId + ) return textResult(`Commented on issue ${issueId}.`, { created: true, messageId }) } @@ -488,7 +563,7 @@ export const createMilestoneTool: HulyTool = { const milestoneId = generateId() const attributes: Record = { name: args.name, - description: (args.description as string | undefined) ?? '', + description: toMarkup((args.description as string | undefined) ?? ''), dueDate: toTimestamp(args.dueDate as string | undefined), project: projectId, done: [] @@ -536,10 +611,6 @@ function toTimestamp (value: string | null | undefined): number | null { return parsed } -function escapeRegExp (value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - export const issueTools: HulyTool[] = [ listIssuesTool, getIssueTool, diff --git a/pods/mcp/src/tools/people-tools.ts b/pods/mcp/src/tools/people-tools.ts index 8f7ac77bab..d42295d19b 100644 --- a/pods/mcp/src/tools/people-tools.ts +++ b/pods/mcp/src/tools/people-tools.ts @@ -21,8 +21,8 @@ import tracker from '@hcengineering/tracker' import { textResult } from '../mcp/protocol' import { booleanProp, objectSchema, stringProp } from '../mcp/schema' -import { type HulyTool } from '../mcp/tool' -import { clampLimit, personNames, statusNames, taskProjectNames, toIso } from './shared' +import { type HulyTool, type ToolContext } from '../mcp/tool' +import { clampLimit, likePattern, personNames, statusNames, taskProjectNames, toIso } from './shared' interface TaskRow { _id: string @@ -108,6 +108,74 @@ export const listTasksTool: HulyTool = { } } +async function allPeople (ctx: ToolContext, limit: number): Promise { + return (await ctx.client.findAll( + contact.class.Person, + {}, + { limit, sort: { name: SortingOrder.Ascending } } + )) as unknown as Person[] +} + +/** + * Matches on name or email in the database rather than after a `limit`, so a + * match is never missed just because it sits beyond the first page of people. + */ +async function matchingPeople (ctx: ToolContext, needle: string, limit: number): Promise { + const like = likePattern(needle) + const nameQuery: Record = { name: { $like: like } } + const valueQuery: Record = { value: { $like: like } } + + const [byName, identityHits, channelHits] = await Promise.all([ + ctx.client.findAll(contact.class.Person, nameQuery as never, { + limit, + sort: { name: SortingOrder.Ascending } + }) as unknown as Promise, + ctx.client.findAll(contact.class.SocialIdentity, valueQuery as never, { limit }) as unknown as Promise< + Array<{ attachedTo: string }> + >, + ctx.client.findAll(contact.class.Channel, valueQuery as never, { limit }) as unknown as Promise< + Array<{ attachedTo: string }> + > + ]) + + const known = new Set(byName.map((person) => person._id as string)) + const extraIds = [...identityHits, ...channelHits].map((hit) => hit.attachedTo).filter((id) => !known.has(id)) + if (extraIds.length === 0) return byName + + const idQuery: Record = { _id: { $in: [...new Set(extraIds)] } } + const byEmail = (await ctx.client.findAll(contact.class.Person, idQuery as never, { + limit + })) as unknown as Person[] + return [...byName, ...byEmail].slice(0, limit) +} + +/** Email per person, from social identities first and legacy channels second. */ +async function emailsOf (ctx: ToolContext, personIds: string[]): Promise> { + const result = new Map() + if (personIds.length === 0) return result + + const attachedQuery: Record = { attachedTo: { $in: personIds } } + const channelQuery: Record = { ...attachedQuery, provider: contact.channelProvider.Email } + const [identities, channels] = await Promise.all([ + ctx.client.findAll(contact.class.SocialIdentity, attachedQuery as never, { + limit: personIds.length * 10 + }) as unknown as Promise>, + ctx.client.findAll( + contact.class.Channel, + channelQuery as never, + { limit: personIds.length * 10 } + ) as unknown as Promise> + ]) + + for (const identity of identities) { + if (identity.type === 'email' && !result.has(identity.attachedTo)) result.set(identity.attachedTo, identity.value) + } + for (const channel of channels) { + if (!result.has(channel.attachedTo)) result.set(channel.attachedTo, channel.value) + } + return result +} + export const findPeopleTool: HulyTool = { name: 'huly_find_people', title: 'Find people', @@ -121,32 +189,19 @@ export const findPeopleTool: HulyTool = { }), handler: async (ctx, args) => { const limit = clampLimit(args.limit) + const needle = (args.query as string | undefined)?.trim() ?? '' - const persons = (await ctx.client.findAll( - contact.class.Person, - {}, - { limit, sort: { name: SortingOrder.Ascending } } - )) as unknown as Person[] - - const identityQuery: Record = { _id: { $in: persons.map((person) => person._id) } } - const identities = await ctx.client.findAll(contact.class.SocialIdentity, identityQuery as never, { - limit: persons.length - }) - - const byPerson = new Map() - for (const identity of identities as unknown as Array<{ _id: string, value: string }>) { - byPerson.set(identity._id, identity.value) - } + const persons = needle === '' ? await allPeople(ctx, limit) : await matchingPeople(ctx, needle, limit) + const emails = await emailsOf( + ctx, + persons.map((person) => person._id) + ) - const needle = (args.query as string | undefined)?.toLowerCase() - const rows = persons - .map((person) => ({ id: person._id, name: person.name, email: byPerson.get(person._id) ?? null })) - .filter( - (row) => - needle === undefined || - row.name.toLowerCase().includes(needle) || - (row.email ?? '').toLowerCase().includes(needle) - ) + const rows = persons.map((person) => ({ + id: person._id, + name: person.name, + email: emails.get(person._id) ?? null + })) if (rows.length === 0) { return textResult('No people matched.', { people: [] }) diff --git a/pods/mcp/src/tools/project-tools.ts b/pods/mcp/src/tools/project-tools.ts index 3a293ac60d..4741b14b8f 100644 --- a/pods/mcp/src/tools/project-tools.ts +++ b/pods/mcp/src/tools/project-tools.ts @@ -81,7 +81,10 @@ export const listProjectsTool: HulyTool = { ]) const projectRows = projects as ProjectRow[] - const taskRows = taskProjects as Array + // A tracker project is also a task project (its base class), so the second + // query returns every tracker project again. Keep only the task-only ones. + const trackerIds = new Set(projectRows.map((project) => project._id)) + const taskRows = (taskProjects as Array).filter((project) => !trackerIds.has(project._id)) const issueCounts = await countIssuesPerProject( ctx.client, projectRows.map((project) => project._id) diff --git a/pods/mcp/src/tools/shared.ts b/pods/mcp/src/tools/shared.ts index 8d6951ff10..1684435727 100644 --- a/pods/mcp/src/tools/shared.ts +++ b/pods/mcp/src/tools/shared.ts @@ -137,8 +137,11 @@ export async function projectNames (client: TxOperations, refs: MaybeId[]): Prom for (const project of projects as unknown as ProjectRow[]) { result.set(project._id, { name: project.name, identifier: project.identifier ?? null }) } + // `task.class.Project` is the base class of `tracker.class.Project`, so this + // query returns the tracker projects again. Only fill in ids not seen yet, or + // the identifier gathered above would be overwritten with null. for (const project of taskProjects as unknown as Array<{ _id: string, name: string }>) { - result.set(project._id, { name: project.name, identifier: null }) + if (!result.has(project._id)) result.set(project._id, { name: project.name, identifier: null }) } return result } @@ -161,3 +164,8 @@ export async function taskProjectNames (client: TxOperations, refs: MaybeId[]): /** Casts a JSON-supplied id to a `Ref` for use in a typed query. */ export const asRef = (id: string): Ref => id as Ref export const asTaskProjectRef = asRef + +/** Builds a case-insensitive "contains" pattern with LIKE wildcards in the input escaped. */ +export function likePattern (needle: string): string { + return `%${needle.replace(/[\\%_]/g, (char) => `\\${char}`)}%` +} From d4f32534279861da823222169acac8de758d9fcc Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:40:25 +0530 Subject: [PATCH 05/32] feat(mcp): add component, sub-issue and milestone tools; fix nullable 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> --- pods/mcp/README.md | 6 +- pods/mcp/src/mcp/__tests__/validation.test.ts | 21 +- pods/mcp/src/mcp/schema.ts | 10 +- pods/mcp/src/mcp/validation.ts | 9 +- pods/mcp/src/tools/__tests__/tools.test.ts | 208 +++++++++++++++++- pods/mcp/src/tools/component-tools.ts | 109 +++++++++ pods/mcp/src/tools/issue-tools.ts | 191 +++++++++++++--- pods/mcp/src/tools/people-tools.ts | 20 +- pods/mcp/src/tools/register.ts | 3 + pods/mcp/src/tools/shared.ts | 5 +- 10 files changed, 536 insertions(+), 46 deletions(-) create mode 100644 pods/mcp/src/tools/component-tools.ts diff --git a/pods/mcp/README.md b/pods/mcp/README.md index ae052b9380..eee174ed71 100644 --- a/pods/mcp/README.md +++ b/pods/mcp/README.md @@ -118,12 +118,12 @@ Read: - `huly_list_issues`, `huly_get_issue`, `huly_list_issue_statuses` - `huly_list_tasks`, `huly_find_people` - `huly_list_spaces`, `huly_list_drives`, `huly_list_documents`, `huly_get_document` -- `huly_list_milestones` +- `huly_list_milestones`, `huly_list_components` Write (refused when read-only): -- `huly_create_issue`, `huly_update_issue`, `huly_add_issue_comment` -- `huly_create_milestone`, `huly_create_person` +- `huly_create_issue` (optionally as a sub-issue via `parentIssueId`, with a `componentId`), `huly_update_issue`, `huly_add_issue_comment` +- `huly_create_milestone`, `huly_update_milestone`, `huly_create_component`, `huly_create_person` ## Security notes diff --git a/pods/mcp/src/mcp/__tests__/validation.test.ts b/pods/mcp/src/mcp/__tests__/validation.test.ts index 15c59b2f0d..c153121944 100644 --- a/pods/mcp/src/mcp/__tests__/validation.test.ts +++ b/pods/mcp/src/mcp/__tests__/validation.test.ts @@ -13,7 +13,7 @@ limitations under the License. */ -import { type JsonSchema, objectSchema } from '../schema' +import { type JsonSchema, nullableStringProp, objectSchema } from '../schema' import { type ValidationResult, validateArguments } from '../validation' const schema: JsonSchema = { @@ -114,4 +114,23 @@ describe('validateArguments', () => { expect(isOk(validateArguments(schema, { title: 'ok', count: 0 }))).toBe(false) expect(isOk(validateArguments(schema, { title: 'ok', count: 6 }))).toBe(false) }) + + describe('nullable types', () => { + const nullable = objectSchema({ due: nullableStringProp('Due date, or null to clear.') }) + + it('accepts null and a string for a nullable field', () => { + expect(isOk(validateArguments(nullable, { due: null }))).toBe(true) + expect(isOk(validateArguments(nullable, { due: '2026-12-31' }))).toBe(true) + }) + + it('still rejects any other type and names both allowed types', () => { + const issues = issuesOf(validateArguments(nullable, { due: 5 })) + expect(issues).toEqual(['due must be of type string or null (received integer)']) + }) + + it('keeps null invalid for a plain string field', () => { + const plain = objectSchema({ name: { type: 'string' } }) + expect(isOk(validateArguments(plain, { name: null }))).toBe(false) + }) + }) }) diff --git a/pods/mcp/src/mcp/schema.ts b/pods/mcp/src/mcp/schema.ts index 3d122cef8b..950f4ca8ad 100644 --- a/pods/mcp/src/mcp/schema.ts +++ b/pods/mcp/src/mcp/schema.ts @@ -24,7 +24,8 @@ export type JsonSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null' export interface JsonSchema { - type?: JsonSchemaType + /** A single type, or a list when the value may be one of several (e.g. `['string', 'null']`). */ + type?: JsonSchemaType | JsonSchemaType[] description?: string title?: string @@ -89,3 +90,10 @@ export const booleanProp = (description: string, extra: Partial = {} description, ...extra }) + +/** A string that may also be `null`, for fields where `null` means "clear this value". */ +export const nullableStringProp = (description: string, extra: Partial = {}): JsonSchema => ({ + type: ['string', 'null'], + description, + ...extra +}) diff --git a/pods/mcp/src/mcp/validation.ts b/pods/mcp/src/mcp/validation.ts index f44c70932d..d9c25a77b4 100644 --- a/pods/mcp/src/mcp/validation.ts +++ b/pods/mcp/src/mcp/validation.ts @@ -67,9 +67,12 @@ function compilePattern (pattern: string): RegExp | undefined { /** Validates a single value against a schema node, collecting issues. */ function validateValue (value: unknown, schema: JsonSchema, path: string, issues: string[]): void { - if (schema.type !== undefined && !matchesType(value, schema.type)) { - issues.push(`${path} must be of type ${schema.type} (received ${typeOf(value)})`) - return + if (schema.type !== undefined) { + const allowed = Array.isArray(schema.type) ? schema.type : [schema.type] + if (!allowed.some((type) => matchesType(value, type))) { + issues.push(`${path} must be of type ${allowed.join(' or ')} (received ${typeOf(value)})`) + return + } } if (schema.enum !== undefined) { diff --git a/pods/mcp/src/tools/__tests__/tools.test.ts b/pods/mcp/src/tools/__tests__/tools.test.ts index 043231580b..68a78f5fa7 100644 --- a/pods/mcp/src/tools/__tests__/tools.test.ts +++ b/pods/mcp/src/tools/__tests__/tools.test.ts @@ -23,8 +23,17 @@ import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' import { toolContext, type ToolContext } from '../../mcp/tool' import { type MarkupWriter } from '../../platform/markup-reader' import { type WorkspaceSession } from '../../platform/workspace-client-provider' -import { addCommentTool, createIssueTool, listIssuesTool } from '../issue-tools' -import { findPeopleTool } from '../people-tools' +import { createComponentTool, listComponentsTool } from '../component-tools' +import { + addCommentTool, + createIssueTool, + createMilestoneTool, + getIssueTool, + listIssuesTool, + updateIssueTool, + updateMilestoneTool +} from '../issue-tools' +import { findPeopleTool, listMilestonesTool } from '../people-tools' import { listProjectsTool } from '../project-tools' import { likePattern, projectNames } from '../shared' @@ -33,6 +42,7 @@ type Row = Record interface Recorded { findAll: Array<{ cls: string, query: Row }> updateDoc: Row[] + createDoc: Array<{ cls: string, space: string, attributes: Row }> addCollection: Array<{ cls: string, space: string, attachedTo: string, collection: string, attributes: Row }> } @@ -43,7 +53,7 @@ interface Script { } function scriptedClient (script: Script): { client: TxOperations, recorded: Recorded } { - const recorded: Recorded = { findAll: [], updateDoc: [], addCollection: [] } + const recorded: Recorded = { findAll: [], updateDoc: [], createDoc: [], addCollection: [] } const client = { findAll: async (cls: string, query: Row) => { recorded.findAll.push({ cls, query }) @@ -51,6 +61,9 @@ function scriptedClient (script: Script): { client: TxOperations, recorded: Reco return typeof rows === 'function' ? rows(query) : (rows ?? []) }, findOne: async (cls: string) => script.findOne?.[cls], + createDoc: async (cls: string, space: string, attributes: Row) => { + recorded.createDoc.push({ cls, space, attributes }) + }, updateDoc: async (...args: unknown[]) => { recorded.updateDoc.push({ args }) return { object: { sequence: script.sequence ?? 1 } } @@ -273,3 +286,192 @@ describe('huly_find_people', () => { expect(payload.people[0].email).toBe('grace@example.test') }) }) + +describe('huly_create_issue relations', () => { + const PARENT = { + _id: 'issue-parent', + title: 'Parent', + identifier: 'HULY-1', + space: 'proj-1', + parents: [{ parentId: 'issue-root', parentTitle: 'Root', space: 'proj-1', identifier: 'HULY-0' }] + } + const base = (): Script => ({ + findOne: { [tracker.class.Project]: PROJECT, [task.class.TaskType]: TASK_TYPE, [tracker.class.Issue]: PARENT }, + sequence: 9 + }) + + it('attaches a sub-issue to its parent and records the ancestor chain nearest first', async () => { + const { client, recorded } = scriptedClient(base()) + + await createIssueTool.handler(context(client), { projectId: 'proj-1', title: 'Child', parentIssueId: 'issue-parent' }) + + const call = recorded.addCollection[0] + expect(call.attachedTo).toBe('issue-parent') + expect(call.attributes.parents).toEqual([ + { parentId: 'issue-parent', parentTitle: 'Parent', space: 'proj-1', identifier: 'HULY-1' }, + { parentId: 'issue-root', parentTitle: 'Root', space: 'proj-1', identifier: 'HULY-0' } + ]) + }) + + it('refuses a parent from another project before consuming a number', async () => { + const { client, recorded } = scriptedClient({ + ...base(), + findOne: { ...base().findOne, [tracker.class.Issue]: { ...PARENT, space: 'other' } } + }) + + const result = await createIssueTool.handler(context(client), { + projectId: 'proj-1', + title: 'Child', + parentIssueId: 'issue-parent' + }) + + expect(JSON.stringify(result.content)).toContain('sub-issue was not created') + expect(recorded.updateDoc).toHaveLength(0) + expect(recorded.addCollection).toHaveLength(0) + }) + + it('sets a component that exists in the project and refuses one that does not', async () => { + const found = scriptedClient({ + ...base(), + findOne: { ...base().findOne, [tracker.class.Component]: { _id: 'comp-1' } } + }) + await createIssueTool.handler(context(found.client), { projectId: 'proj-1', title: 'A', componentId: 'comp-1' }) + expect(found.recorded.addCollection[0].attributes.component).toBe('comp-1') + + const missing = scriptedClient(base()) + const result = await createIssueTool.handler(context(missing.client), { + projectId: 'proj-1', + title: 'B', + componentId: 'comp-x' + }) + expect(JSON.stringify(result.content)).toContain('huly_list_components') + expect(missing.recorded.updateDoc).toHaveLength(0) + }) +}) + +describe('huly_update_issue component', () => { + const issue = { _id: 'issue-1', space: 'proj-1' } + + it('validates the component against the issue project and allows clearing it', async () => { + const ok = scriptedClient({ + findOne: { [tracker.class.Issue]: issue, [tracker.class.Component]: { _id: 'comp-1' } } + }) + await updateIssueTool.handler(context(ok.client), { issueId: 'issue-1', componentId: 'comp-1' }) + expect(ok.recorded.updateDoc[0]).toMatchObject({ args: [tracker.class.Issue, 'proj-1', 'issue-1', { component: 'comp-1' }] }) + + const cleared = scriptedClient({ findOne: { [tracker.class.Issue]: issue } }) + await updateIssueTool.handler(context(cleared.client), { issueId: 'issue-1', componentId: null }) + expect(cleared.recorded.updateDoc[0]).toMatchObject({ args: [tracker.class.Issue, 'proj-1', 'issue-1', { component: null }] }) + + const bad = scriptedClient({ findOne: { [tracker.class.Issue]: issue } }) + const result = await updateIssueTool.handler(context(bad.client), { issueId: 'issue-1', componentId: 'nope' }) + expect(JSON.stringify(result.content)).toContain('nothing was changed') + expect(bad.recorded.updateDoc).toHaveLength(0) + }) +}) + +describe('milestones', () => { + it('creates a milestone with the model fields, in the project space', async () => { + const { client, recorded } = scriptedClient({ findOne: { [tracker.class.Project]: PROJECT } }) + + await createMilestoneTool.handler(context(client), { + projectId: 'proj-1', + name: 'Beta', + targetDate: '2026-12-31T00:00:00Z' + }) + + expect(recorded.createDoc).toHaveLength(1) + const { cls, space, attributes } = recorded.createDoc[0] + expect(cls).toBe(tracker.class.Milestone) + expect(space).toBe('proj-1') + expect(attributes).toMatchObject({ + label: 'Beta', + status: 0, + comments: 0, + startDate: null, + targetDate: Date.parse('2026-12-31T00:00:00Z') + }) + expect(attributes).not.toHaveProperty('name') + expect(attributes).not.toHaveProperty('project') + }) + + it('lists milestones by space and reports status and dates', async () => { + const { client, recorded } = scriptedClient({ + findAll: { + [tracker.class.Milestone]: [ + { _id: 'ms-1', label: 'Beta', status: 1, startDate: null, targetDate: Date.parse('2026-12-31T00:00:00Z') } + ] + } + }) + + const result = await listMilestonesTool.handler(context(client), { projectId: 'proj-1' }) + + expect(recorded.findAll[0].query).toEqual({ space: 'proj-1' }) + const payload = JSON.parse((result.content[0] as { text: string }).text) as { milestones: Row[] } + expect(payload.milestones[0]).toEqual({ + id: 'ms-1', + name: 'Beta', + status: 'InProgress', + startDate: null, + targetDate: '2026-12-31T00:00:00.000Z' + }) + }) + + it('updates only the passed fields and refuses to clear the target date', async () => { + const milestone = { _id: 'ms-1', space: 'proj-1' } + const { client, recorded } = scriptedClient({ findOne: { [tracker.class.Milestone]: milestone } }) + + await updateMilestoneTool.handler(context(client), { milestoneId: 'ms-1', name: 'GA', status: 'Completed' }) + expect(recorded.updateDoc[0]).toMatchObject({ + args: [tracker.class.Milestone, 'proj-1', 'ms-1', { label: 'GA', status: 2 }] + }) + + const result = await updateMilestoneTool.handler(context(client), { milestoneId: 'ms-1', targetDate: null }) + expect(JSON.stringify(result.content)).toContain('cannot be cleared') + expect(recorded.updateDoc).toHaveLength(1) + + const unknown = await updateMilestoneTool.handler(context(client), { milestoneId: 'ms-1', status: 'Done' }) + expect(JSON.stringify(unknown.content)).toContain('Unknown milestone status') + }) +}) + +describe('components', () => { + it('lists components with lead names', async () => { + const { client, recorded } = scriptedClient({ + findAll: { + [tracker.class.Component]: [{ _id: 'comp-1', label: 'API', lead: 'person-1', space: 'proj-1' }], + [contact.class.Person]: [{ _id: 'person-1', name: 'Grace Hopper' }] + } + }) + + const result = await listComponentsTool.handler(context(client), { projectId: 'proj-1' }) + + expect(recorded.findAll[0].query).toEqual({ space: 'proj-1' }) + const payload = JSON.parse((result.content[0] as { text: string }).text) as { components: Row[] } + expect(payload.components[0]).toEqual({ id: 'comp-1', name: 'API', projectId: 'proj-1', lead: 'Grace Hopper' }) + }) + + it('creates a component through createDoc in the project space', async () => { + const { client, recorded } = scriptedClient({ findOne: { [tracker.class.Project]: PROJECT } }) + + await createComponentTool.handler(context(client), { projectId: 'proj-1', name: 'API', lead: 'person-1' }) + + expect(recorded.createDoc[0]).toMatchObject({ + cls: tracker.class.Component, + space: 'proj-1', + attributes: { label: 'API', lead: 'person-1', comments: 0 } + }) + }) +}) + +describe('huly_get_issue', () => { + it('finds sub-issues by attachment to the parent, not by a nonexistent parent field', async () => { + const issue = { _id: 'issue-1', number: 1, title: 'P', status: 's', priority: 0, assignee: null, space: 'proj-1', labels: 0, rank: '', createdOn: 0, modifiedOn: 0, startDate: null, dueDate: null, milestone: null, estimation: 0 } + const { client, recorded } = scriptedClient({ findOne: { [tracker.class.Issue]: issue } }) + + await getIssueTool.handler(context(client), { issueId: 'issue-1' }) + + const subtaskQuery = recorded.findAll.find((call) => call.cls === tracker.class.Issue)?.query + expect(subtaskQuery).toEqual({ space: 'proj-1', attachedTo: 'issue-1' }) + }) +}) diff --git a/pods/mcp/src/tools/component-tools.ts b/pods/mcp/src/tools/component-tools.ts new file mode 100644 index 0000000000..103645f557 --- /dev/null +++ b/pods/mcp/src/tools/component-tools.ts @@ -0,0 +1,109 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { generateId } from '@hcengineering/core' +import tracker, { type Component } from '@hcengineering/tracker' + +import { textResult } from '../mcp/protocol' +import { objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool } from '../mcp/tool' +import { toMarkup } from '../platform/markup' +import { clampLimit, personNames } from './shared' + +interface ComponentRow { + _id: string + label: string + lead: string | null + space: string +} + +export const listComponentsTool: HulyTool = { + name: 'huly_list_components', + title: 'List components', + description: + 'List the components of a project (the areas of a product that issues are grouped under). ' + + 'Component ids are what huly_create_issue and huly_update_issue expect as componentId.', + readOnly: true, + inputSchema: objectSchema({ + projectId: stringProp('Restrict to one project id from huly_list_projects.'), + limit: { type: 'integer', description: 'Maximum components to return (1-200, default 50).', default: 50 } + }), + handler: async (ctx, args) => { + const query: Record = {} + if (args.projectId !== undefined) query.space = args.projectId + + const components = (await ctx.client.findAll(tracker.class.Component, query as never, { + limit: clampLimit(args.limit) + })) as unknown as ComponentRow[] + + if (components.length === 0) { + return textResult('No components found.', { components: [] }) + } + + const leads = await personNames( + ctx.client, + components.map((component) => component.lead) + ) + const rows = components.map((component) => ({ + id: component._id, + name: component.label, + projectId: component.space, + lead: component.lead === null ? null : (leads.get(component.lead) ?? 'Unknown') + })) + + return textResult(JSON.stringify({ components: rows }, null, 2), { components: rows }) + } +} + +export const createComponentTool: HulyTool = { + name: 'huly_create_component', + title: 'Create component', + description: 'Create a component in a project. Use huly_find_people to look up a lead.', + readOnly: false, + inputSchema: objectSchema( + { + projectId: stringProp('Project id from huly_list_projects.'), + name: stringProp('Component name.', { minLength: 1, maxLength: 200 }), + description: stringProp('Component description. Plain text or Markdown.', { maxLength: 20_000 }), + lead: stringProp('Person id of the component lead.') + }, + ['projectId', 'name'] + ), + handler: async (ctx, args) => { + const projectId = args.projectId as string + const projectQuery: Record = { _id: projectId } + const project = await ctx.client.findOne(tracker.class.Project, projectQuery as never) + + if (project === undefined) { + return textResult(`No project with id ${projectId} is visible to you, so nothing was created.`, { + created: false + }) + } + + const componentId = generateId() + const attributes: Record = { + label: args.name, + description: toMarkup((args.description as string | undefined) ?? ''), + lead: (args.lead as string | undefined) ?? null, + comments: 0, + attachments: 0 + } + await ctx.client.createDoc(tracker.class.Component, projectId as never, attributes as never, componentId) + + return textResult(JSON.stringify({ componentId, name: args.name }, null, 2), { created: true, componentId }) + } +} + +export const componentTools: HulyTool[] = [listComponentsTool, createComponentTool] diff --git a/pods/mcp/src/tools/issue-tools.ts b/pods/mcp/src/tools/issue-tools.ts index e54c39c254..5246db3938 100644 --- a/pods/mcp/src/tools/issue-tools.ts +++ b/pods/mcp/src/tools/issue-tools.ts @@ -16,13 +16,13 @@ import chunter, { type ChatMessage } from '@hcengineering/chunter' import core, { generateId, SortingOrder } from '@hcengineering/core' import task from '@hcengineering/task' -import tracker, { IssuePriority, type Issue, type Milestone } from '@hcengineering/tracker' +import tracker, { IssuePriority, MilestoneStatus, type Issue, type Milestone } from '@hcengineering/tracker' import { textResult } from '../mcp/protocol' import { toMarkup } from '../platform/markup' -import { booleanProp, objectSchema, stringProp } from '../mcp/schema' +import { booleanProp, nullableStringProp, objectSchema, stringProp } from '../mcp/schema' import { type HulyTool, type ToolContext } from '../mcp/tool' -import { clampLimit, likePattern, personNames, projectNames, statusNames, toIso } from './shared' +import { clampLimit, likePattern, MILESTONE_STATUS_NAMES, personNames, projectNames, statusNames, toIso } from './shared' /** The slice of an Issue the tools read. */ interface IssueRow { @@ -40,10 +40,14 @@ interface IssueRow { startDate: number | null dueDate: number | null milestone: string | null + component?: string | null description?: string | null estimation: number } +/** How far ahead a milestone's target date lands when the caller gives none; matches the web client. */ +const DEFAULT_MILESTONE_SPAN_MS = 14 * 24 * 60 * 60 * 1000 + const PRIORITY_NAMES = Object.keys(IssuePriority).filter((key) => Number.isNaN(Number(key))) const priorityName = (value: number): string => PRIORITY_NAMES[value] ?? `Unknown(${value})` @@ -90,6 +94,8 @@ async function formatIssues (ctx: ToolContext, issues: IssueRow[]): Promise = { space: issue.space, parent: issue._id } + // Sub-issues are attached documents of their parent; an issue has no `parent` field. + const subtaskQuery: Record = { space: issue.space, attachedTo: issue._id } const commentQuery: Record = { attachedTo: issueId, collection: 'comments' } const [subtasks, comments, description] = await Promise.all([ @@ -300,7 +307,9 @@ export const createIssueTool: HulyTool = { assignee: stringProp('Person id to assign. Use huly_find_people to look one up.'), dueDate: stringProp('ISO-8601 due date, e.g. 2026-12-31 or 2026-12-31T17:00:00Z.'), startDate: stringProp('ISO-8601 start date.'), - milestone: stringProp('Milestone id to attach the issue to.') + milestone: stringProp('Milestone id to attach the issue to.'), + componentId: stringProp('Component id from huly_list_components. Must belong to the same project.'), + parentIssueId: stringProp('Create this as a sub-issue of the issue with this id. Must be in the same project.') }, ['projectId', 'title'] ), @@ -334,6 +343,28 @@ export const createIssueTool: HulyTool = { ) } + // Every check that can refuse the request runs before the sequence counter + // is incremented, so a rejected call never burns an issue number. + let parent: ParentRow | undefined + if (args.parentIssueId !== undefined) { + const parentQuery: Record = { _id: args.parentIssueId } + parent = (await ctx.client.findOne(tracker.class.Issue, parentQuery as never)) as unknown as ParentRow | undefined + if (parent === undefined || parent.space !== projectId) { + return textResult( + `No issue with id ${String(args.parentIssueId)} exists in project ${projectId}, so the sub-issue was not created.`, + { created: false } + ) + } + } + + if (args.componentId !== undefined && !(await componentInProject(ctx, args.componentId as string, projectId))) { + return textResult( + `No component with id ${String(args.componentId)} exists in project ${projectId}, so the issue was not ` + + 'created. Call huly_list_components for the valid ids.', + { created: false } + ) + } + // The body is stored as a blob owned by the collaborator service. Refuse up // front, before a number is consumed, when that service is not configured. const issueId = generateId() @@ -371,7 +402,7 @@ export const createIssueTool: HulyTool = { title: args.title, description: descriptionRef, assignee: (args.assignee as string | undefined) ?? null, - component: null, + component: (args.componentId as string | undefined) ?? null, milestone: (args.milestone as string | undefined) ?? null, number, identifier, @@ -381,7 +412,19 @@ export const createIssueTool: HulyTool = { rank: '', comments: 0, subIssues: 0, - parents: [], + // Ancestors nearest first, as the web client stores them. + parents: + parent === undefined + ? [] + : [ + { + parentId: parent._id, + parentTitle: parent.title, + space: parent.space, + identifier: parent.identifier + }, + ...(parent.parents ?? []) + ], childInfo: [], relations: [], startDate: toTimestamp(args.startDate as string | undefined), @@ -395,20 +438,34 @@ export const createIssueTool: HulyTool = { await ctx.client.addCollection( tracker.class.Issue, projectId as never, - tracker.ids.NoParent, + (parent?._id ?? tracker.ids.NoParent) as never, tracker.class.Issue, 'subIssues', attributes as never, issueId ) - return textResult(JSON.stringify({ id: issueId, key: identifier, title: args.title }, null, 2), { - created: true, - issueId - }) + return textResult( + JSON.stringify({ id: issueId, key: identifier, title: args.title, parentIssueId: parent?._id ?? null }, null, 2), + { created: true, issueId } + ) } } +interface ParentRow { + _id: string + title: string + identifier: string + space: string + parents?: Array<{ parentId: string, parentTitle: string, space: string, identifier: string }> +} + +/** True when the component exists and belongs to the given project. */ +async function componentInProject (ctx: ToolContext, componentId: string, projectId: string): Promise { + const query: Record = { _id: componentId, space: projectId } + return (await ctx.client.findOne(tracker.class.Component, query as never)) !== undefined +} + interface ProjectDefaults { identifier?: string type?: string @@ -425,8 +482,8 @@ export const updateIssueTool: HulyTool = { title: 'Update issue', description: 'Change fields on an existing issue. Only the fields you pass are changed; everything else is left alone. ' + - 'Use statusId from huly_list_issue_statuses. Passing null for dueDate, startDate, assignee or ' + - 'milestone clears that field.', + 'Use statusId from huly_list_issue_statuses. Passing null for dueDate, startDate, assignee, ' + + 'milestone or componentId clears that field.', readOnly: false, inputSchema: objectSchema( { @@ -438,10 +495,11 @@ export const updateIssueTool: HulyTool = { description: 'New priority.', enum: ['NoPriority', 'Urgent', 'High', 'Medium', 'Low'] }, - assignee: stringProp('New assignee person id, or null to unassign.'), - dueDate: stringProp('New ISO-8601 due date, or null to clear it.'), - startDate: stringProp('New ISO-8601 start date, or null to clear it.'), - milestone: stringProp('New milestone id, or null to detach.') + assignee: nullableStringProp('New assignee person id, or null to unassign.'), + dueDate: nullableStringProp('New ISO-8601 due date, or null to clear it.'), + startDate: nullableStringProp('New ISO-8601 start date, or null to clear it.'), + milestone: nullableStringProp('New milestone id, or null to detach.'), + componentId: nullableStringProp('New component id from huly_list_components, or null to clear it.') }, ['issueId'] ), @@ -468,11 +526,21 @@ export const updateIssueTool: HulyTool = { if (args.dueDate !== undefined) operations.dueDate = toTimestamp(args.dueDate as string) if (args.startDate !== undefined) operations.startDate = toTimestamp(args.startDate as string) if (args.milestone !== undefined) operations.milestone = args.milestone + if (args.componentId !== undefined) { + if (args.componentId !== null && !(await componentInProject(ctx, args.componentId as string, issue.space))) { + return textResult( + `No component with id ${String(args.componentId)} exists in the issue's project, so nothing was changed. ` + + 'Call huly_list_components for the valid ids.', + { updated: false } + ) + } + operations.component = args.componentId + } const changed = Object.keys(operations) if (changed.length === 0) { return textResult( - 'No fields to update. Pass at least one of title, statusId, priority, assignee, dueDate or milestone.', + 'No fields to update. Pass at least one of title, statusId, priority, assignee, dueDate, startDate, milestone or componentId.', { updated: false } ) } @@ -539,7 +607,8 @@ export const createMilestoneTool: HulyTool = { projectId: stringProp('Project id from huly_list_projects.'), name: stringProp('Milestone name.', { minLength: 1, maxLength: 200 }), description: stringProp('Milestone description.', { maxLength: 20_000 }), - dueDate: stringProp('ISO-8601 target date.'), + startDate: stringProp('ISO-8601 start date. Omit for an open-ended start.'), + targetDate: stringProp('ISO-8601 target date. Defaults to two weeks from now.'), issueIds: { type: 'array', description: 'Issue ids to attach to the new milestone.', @@ -562,11 +631,13 @@ export const createMilestoneTool: HulyTool = { const milestoneId = generateId() const attributes: Record = { - name: args.name, + label: args.name, description: toMarkup((args.description as string | undefined) ?? ''), - dueDate: toTimestamp(args.dueDate as string | undefined), - project: projectId, - done: [] + status: MilestoneStatus.Planned, + comments: 0, + attachments: 0, + startDate: toTimestamp(args.startDate as string | undefined), + targetDate: toTimestamp(args.targetDate as string | undefined) ?? Date.now() + DEFAULT_MILESTONE_SPAN_MS } await ctx.client.createDoc(tracker.class.Milestone, projectId as never, attributes as never, milestoneId) @@ -602,6 +673,75 @@ export const createMilestoneTool: HulyTool = { } } +export const updateMilestoneTool: HulyTool = { + name: 'huly_update_milestone', + title: 'Update milestone', + description: + 'Change fields on an existing milestone. Only the fields you pass are changed. ' + + 'Passing null for startDate makes the start open-ended; targetDate cannot be cleared.', + readOnly: false, + inputSchema: objectSchema( + { + milestoneId: stringProp('Milestone id from huly_list_milestones.'), + name: stringProp('New name.', { minLength: 1, maxLength: 200 }), + description: stringProp('New description. Plain text or Markdown.', { maxLength: 20_000 }), + status: { + type: 'string', + description: 'New status.', + enum: MILESTONE_STATUS_NAMES + }, + startDate: nullableStringProp('New ISO-8601 start date, or null for an open-ended start.'), + targetDate: stringProp('New ISO-8601 target date.') + }, + ['milestoneId'] + ), + handler: async (ctx, args) => { + const milestoneId = args.milestoneId as string + const query: Record = { _id: milestoneId } + const milestone = (await ctx.client.findOne(tracker.class.Milestone, query as never)) as unknown as + | { _id: string, space: string } + | undefined + + if (milestone === undefined) { + return textResult(`No milestone with id ${milestoneId} is visible to you, so nothing was changed.`, { + updated: false + }) + } + + const operations: Record = {} + if (args.name !== undefined) operations.label = args.name + if (args.description !== undefined) operations.description = toMarkup(args.description as string) + if (args.status !== undefined) { + const index = MILESTONE_STATUS_NAMES.indexOf(args.status as string) + if (index < 0) { + return textResult(`Unknown milestone status "${String(args.status)}". Use ${MILESTONE_STATUS_NAMES.join(', ')}.`, { + updated: false + }) + } + operations.status = index + } + if (args.startDate !== undefined) operations.startDate = toTimestamp(args.startDate as string | null) + if (args.targetDate !== undefined) { + const target = toTimestamp(args.targetDate as string | null) + if (target === null) { + return textResult('targetDate cannot be cleared; pass an ISO-8601 date.', { updated: false }) + } + operations.targetDate = target + } + + const changed = Object.keys(operations) + if (changed.length === 0) { + return textResult('No fields to update. Pass at least one of name, description, status, startDate or targetDate.', { + updated: false + }) + } + + await ctx.client.updateDoc(tracker.class.Milestone, milestone.space as never, milestone._id as never, operations as never) + + return textResult(`Updated milestone ${milestoneId}: ${changed.sort().join(', ')}.`, { updated: true, changed }) + } +} + function toTimestamp (value: string | null | undefined): number | null { if (value === undefined || value === null || value === '') return null const parsed = Date.parse(value) @@ -618,5 +758,6 @@ export const issueTools: HulyTool[] = [ createIssueTool, updateIssueTool, addCommentTool, - createMilestoneTool + createMilestoneTool, + updateMilestoneTool ] diff --git a/pods/mcp/src/tools/people-tools.ts b/pods/mcp/src/tools/people-tools.ts index d42295d19b..344d3a0264 100644 --- a/pods/mcp/src/tools/people-tools.ts +++ b/pods/mcp/src/tools/people-tools.ts @@ -22,7 +22,7 @@ import tracker from '@hcengineering/tracker' import { textResult } from '../mcp/protocol' import { booleanProp, objectSchema, stringProp } from '../mcp/schema' import { type HulyTool, type ToolContext } from '../mcp/tool' -import { clampLimit, likePattern, personNames, statusNames, taskProjectNames, toIso } from './shared' +import { clampLimit, likePattern, MILESTONE_STATUS_NAMES, personNames, statusNames, taskProjectNames, toIso } from './shared' interface TaskRow { _id: string @@ -40,9 +40,10 @@ interface TaskRow { interface MilestoneRow { _id: string - name: string - dueDate?: number - done?: string[] + label: string + status: number + startDate: number | null + targetDate: number } export const listTasksTool: HulyTool = { @@ -317,20 +318,21 @@ export const listDrivesTool: HulyTool = { export const listMilestonesTool: HulyTool = { name: 'huly_list_milestones', title: 'List milestones', - description: 'List the milestones of a project, with their target dates and how many issues are done.', + description: 'List the milestones of a project with their status, start date and target date.', readOnly: true, inputSchema: objectSchema({ projectId: stringProp('Project id.') }, ['projectId']), handler: async (ctx, args) => { - const query: Record = { project: args.projectId } + const query: Record = { space: args.projectId } const milestones = (await ctx.client.findAll(tracker.class.Milestone, query as never, { limit: 200 })) as unknown as MilestoneRow[] const rows = milestones.map((milestone) => ({ id: milestone._id, - name: milestone.name, - dueDate: toIso(milestone.dueDate), - doneCount: milestone.done?.length ?? 0 + name: milestone.label, + status: MILESTONE_STATUS_NAMES[milestone.status] ?? `Unknown(${milestone.status})`, + startDate: toIso(milestone.startDate), + targetDate: toIso(milestone.targetDate) })) return textResult( diff --git a/pods/mcp/src/tools/register.ts b/pods/mcp/src/tools/register.ts index a72d1b8b0d..94a56dc777 100644 --- a/pods/mcp/src/tools/register.ts +++ b/pods/mcp/src/tools/register.ts @@ -14,6 +14,7 @@ */ import { ToolRegistry } from '../mcp/tool' +import { componentTools } from './component-tools' import { documentTools } from './document-tools' import { issueTools } from './issue-tools' import { personTools } from './people-tools' @@ -32,6 +33,7 @@ export function buildRegistry (): ToolRegistry { ...searchTools, ...projectTools, ...issueTools, + ...componentTools, ...personTools, ...documentTools ]) @@ -49,6 +51,7 @@ export const MCP_INSTRUCTIONS = [ 'Writing:', '- Issue statuses are documents, not strings. Call huly_list_issue_statuses to get valid ids', ' before huly_update_issue.', + '- Sub-issues are created with huly_create_issue and parentIssueId; components come from huly_list_components.', '- People are referenced by id. Call huly_find_people to resolve a name to an id.', '- Tools that modify data are refused when the server runs in read-only mode.', '', diff --git a/pods/mcp/src/tools/shared.ts b/pods/mcp/src/tools/shared.ts index 1684435727..f3b9a38d71 100644 --- a/pods/mcp/src/tools/shared.ts +++ b/pods/mcp/src/tools/shared.ts @@ -16,7 +16,10 @@ import contact from '@hcengineering/contact' import core, { type Class, type Doc, type Ref, type TxOperations } from '@hcengineering/core' import task, { type Project as TaskProject } from '@hcengineering/task' -import tracker from '@hcengineering/tracker' +import tracker, { MilestoneStatus } from '@hcengineering/tracker' + +/** Names of the milestone statuses, indexed by their enum value. */ +export const MILESTONE_STATUS_NAMES = Object.keys(MilestoneStatus).filter((key) => Number.isNaN(Number(key))) /** Hard ceiling on rows any list tool may return, regardless of what is asked. */ export const MAX_PAGE_SIZE = 200 From 346b617d4f48d9d84579406c8df0b7b21d210f5e Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:21:23 +0530 Subject: [PATCH 06/32] feat(mcp): add workspace administration tools backed by the account service - 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> --- pods/mcp/README.md | 4 + pods/mcp/src/index.ts | 2 + pods/mcp/src/platform/account-api.ts | 79 +++++ .../src/platform/workspace-client-provider.ts | 7 + .../src/tools/__tests__/account-tools.test.ts | 239 +++++++++++++++ pods/mcp/src/tools/__tests__/tools.test.ts | 1 + pods/mcp/src/tools/account-tools.ts | 279 ++++++++++++++++++ pods/mcp/src/tools/register.ts | 2 + 8 files changed, 613 insertions(+) create mode 100644 pods/mcp/src/platform/account-api.ts create mode 100644 pods/mcp/src/tools/__tests__/account-tools.test.ts create mode 100644 pods/mcp/src/tools/account-tools.ts diff --git a/pods/mcp/README.md b/pods/mcp/README.md index eee174ed71..fac9fd7796 100644 --- a/pods/mcp/README.md +++ b/pods/mcp/README.md @@ -119,11 +119,15 @@ Read: - `huly_list_tasks`, `huly_find_people` - `huly_list_spaces`, `huly_list_drives`, `huly_list_documents`, `huly_get_document` - `huly_list_milestones`, `huly_list_components` +- `huly_get_workspace` (settings and the caller's own role), `huly_list_members` Write (refused when read-only): - `huly_create_issue` (optionally as a sub-issue via `parentIssueId`, with a `componentId`), `huly_update_issue`, `huly_add_issue_comment` - `huly_create_milestone`, `huly_update_milestone`, `huly_create_component`, `huly_create_person` +- Workspace administration, enforced by the account service against the caller's role: `huly_update_workspace_name`, + `huly_update_workspace_guest_settings`, `huly_set_member_role`, `huly_remove_member`, `huly_invite_member`, + `huly_create_invite_link`. A refusal comes back as a plain sentence naming the action the role does not allow. ## Security notes diff --git a/pods/mcp/src/index.ts b/pods/mcp/src/index.ts index 927ccf8890..681ba84745 100644 --- a/pods/mcp/src/index.ts +++ b/pods/mcp/src/index.ts @@ -24,6 +24,7 @@ import { join } from 'node:path' import { createAuthenticator } from './auth/authenticator-factory' import { loadConfig } from './config' import { RateLimiter } from './middleware/rate-limiter' +import { createAccountApi } from './platform/account-api' import { createCollaboratorWriter } from './platform/collaborator-writer' import { CachingWorkspaceClientProvider } from './platform/workspace-client-provider' import { createServer, listen } from './server' @@ -66,6 +67,7 @@ async function main (): Promise { const clients = new CachingWorkspaceClientProvider({ ctx, idleTtlMs: config.ClientCacheTtlMs, + createAccounts: createAccountApi(config.AccountsUrl), createMarkupWriter: createCollaboratorWriter(config.CollaboratorUrl) }) const limiter = new RateLimiter(ctx, config.RequestRateLimit, config.RequestRateWindowMs) diff --git a/pods/mcp/src/platform/account-api.ts b/pods/mcp/src/platform/account-api.ts new file mode 100644 index 0000000000..a35f4a5c77 --- /dev/null +++ b/pods/mcp/src/platform/account-api.ts @@ -0,0 +1,79 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { getClient as getAccountClient } from '@hcengineering/account-client' +import { + type AccountRole, + type AccountUuid, + type WorkspaceInfoWithStatus, + type WorkspaceMemberInfo +} from '@hcengineering/core' + +import { type SessionIdentity } from '../auth/authenticator' + +/** + * The slice of the account service the tools use. + * + * Kept narrow on purpose: the full `AccountClient` has a hundred methods, most + * of which (merging accounts, deleting a workspace, minting tokens) an agent + * should not reach. Every call runs with the caller's own workspace token, so + * the account service applies its own role checks; nothing here grants rights. + */ +export interface AccountApi { + getWorkspaceInfo: () => Promise + getWorkspaceMembers: () => Promise + updateWorkspaceName: (name: string) => Promise + updateWorkspaceRole: (account: AccountUuid, role: AccountRole) => Promise + /** Removes a member (the account service calls this "leaving"). */ + removeMember: (account: AccountUuid) => Promise + sendInvite: (email: string, role: AccountRole) => Promise + createInviteLink: (email: string, role: AccountRole, expHours: number) => Promise + updateAllowReadOnlyGuests: (allowed: boolean) => Promise + updateAllowGuestSignUp: (allowed: boolean) => Promise +} + +export type AccountApiFactory = (identity: SessionIdentity) => AccountApi + +/** Builds the account API over the real account client, authenticated as the caller. */ +export const createAccountApi = (accountsUrl: string): AccountApiFactory => { + return (identity) => { + const client = getAccountClient(accountsUrl, identity.workspaceToken) + return { + getWorkspaceInfo: async () => await client.getWorkspaceInfo(), + getWorkspaceMembers: async () => await client.getWorkspaceMembers(), + updateWorkspaceName: async (name) => { + await client.updateWorkspaceName(name) + }, + updateWorkspaceRole: async (account, role) => { + await client.updateWorkspaceRole(account, role) + }, + removeMember: async (account) => { + await client.leaveWorkspace(account) + }, + sendInvite: async (email, role) => { + await client.sendInvite(email, role) + }, + // Auto-join is off: the invitee must accept, rather than being enrolled by whoever holds the link. + createInviteLink: async (email, role, expHours) => + await client.createInviteLink(email, role, false, '', '', undefined, expHours), + updateAllowReadOnlyGuests: async (allowed) => { + await client.updateAllowReadOnlyGuests(allowed) + }, + updateAllowGuestSignUp: async (allowed) => { + await client.updateAllowGuestSignUp(allowed) + } + } + } +} diff --git a/pods/mcp/src/platform/workspace-client-provider.ts b/pods/mcp/src/platform/workspace-client-provider.ts index 0add8a7a53..2406ed1682 100644 --- a/pods/mcp/src/platform/workspace-client-provider.ts +++ b/pods/mcp/src/platform/workspace-client-provider.ts @@ -17,12 +17,15 @@ import { createRestTxOperations } from '@hcengineering/api-client' import { type AccountUuid, type MeasureContext, type TxOperations, type WorkspaceUuid } from '@hcengineering/core' import { type SessionIdentity } from '../auth/authenticator' +import { type AccountApi, type AccountApiFactory } from './account-api' import { type MarkupReader, type MarkupWriter } from './markup-reader' import { fromMarkup } from './markup' /** Everything a tool needs to talk to one workspace on behalf of one user. */ export interface WorkspaceSession { client: TxOperations + /** Account-service calls (members, roles, workspace settings), made as the caller. */ + accounts: AccountApi identity: SessionIdentity markup: MarkupReader /** Undefined when no collaborator service is configured. */ @@ -40,6 +43,7 @@ export type ClientFactory = (identity: SessionIdentity) => Promise export interface WorkspaceClientProviderOptions { ctx: MeasureContext createClient?: ClientFactory + createAccounts: AccountApiFactory createMarkupReader?: (identity: SessionIdentity) => MarkupReader createMarkupWriter?: (identity: SessionIdentity) => MarkupWriter | undefined /** Idle time after which a cached client is closed, in milliseconds. */ @@ -75,6 +79,7 @@ const keyOf = (account: AccountUuid, workspace: WorkspaceUuid): string => `${acc export class CachingWorkspaceClientProvider implements WorkspaceClientProvider { private readonly ctx: MeasureContext private readonly createClient: ClientFactory + private readonly createAccounts: AccountApiFactory private readonly createMarkupReader: (identity: SessionIdentity) => MarkupReader private readonly createMarkupWriter: (identity: SessionIdentity) => MarkupWriter | undefined private readonly idleTtlMs: number @@ -86,6 +91,7 @@ export class CachingWorkspaceClientProvider implements WorkspaceClientProvider { constructor (options: WorkspaceClientProviderOptions) { this.ctx = options.ctx this.createClient = options.createClient ?? defaultClientFactory + this.createAccounts = options.createAccounts this.createMarkupReader = options.createMarkupReader ?? defaultMarkupReaderFactory this.createMarkupWriter = options.createMarkupWriter ?? (() => undefined) this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS @@ -116,6 +122,7 @@ export class CachingWorkspaceClientProvider implements WorkspaceClientProvider { const client = await this.createClient(identity) const session: WorkspaceSession = { client, + accounts: this.createAccounts(identity), identity, markup: this.createMarkupReader(identity), markupWriter: this.createMarkupWriter(identity) diff --git a/pods/mcp/src/tools/__tests__/account-tools.test.ts b/pods/mcp/src/tools/__tests__/account-tools.test.ts new file mode 100644 index 0000000000..b6f5c787a3 --- /dev/null +++ b/pods/mcp/src/tools/__tests__/account-tools.test.ts @@ -0,0 +1,239 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { AccountRole, type AccountUuid, type TxOperations } from '@hcengineering/core' +import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' + +import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' +import { toolContext, type ToolContext } from '../../mcp/tool' +import { type AccountApi } from '../../platform/account-api' +import { type WorkspaceSession } from '../../platform/workspace-client-provider' +import { + accountTools, + createInviteLinkTool, + getWorkspaceTool, + inviteMemberTool, + listMembersTool, + removeMemberTool, + setMemberRoleTool, + updateGuestSettingsTool, + updateWorkspaceNameTool +} from '../account-tools' + +const ME = 'account-1' as AccountUuid +const OTHER = 'account-2' as AccountUuid + +const forbidden = (): PlatformError> => new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + +function fakeAccounts (overrides: Partial = {}): { accounts: AccountApi, calls: Array<[string, unknown[]]> } { + const calls: Array<[string, unknown[]]> = [] + const record = (name: string, result?: unknown) => async (...args: unknown[]) => { + calls.push([name, args]) + return result + } + const accounts = { + getWorkspaceInfo: record('getWorkspaceInfo', { + uuid: 'ws-1', + name: 'Acme', + url: 'acme', + createdOn: Date.parse('2026-01-01T00:00:00Z'), + allowReadOnlyGuest: true + }), + getWorkspaceMembers: record('getWorkspaceMembers', [ + { person: ME, role: AccountRole.Owner }, + { person: OTHER, role: AccountRole.User } + ]), + updateWorkspaceName: record('updateWorkspaceName'), + updateWorkspaceRole: record('updateWorkspaceRole'), + removeMember: record('removeMember'), + sendInvite: record('sendInvite'), + createInviteLink: record('createInviteLink', 'https://huly.test/invite/abc'), + updateAllowReadOnlyGuests: record('updateAllowReadOnlyGuests'), + updateAllowGuestSignUp: record('updateAllowGuestSignUp'), + ...overrides + } + return { accounts: accounts as unknown as AccountApi, calls } +} + +const PERSONS = [ + { _id: 'p1', name: 'Grace Hopper', personUuid: ME }, + { _id: 'p2', name: 'Alan Turing', personUuid: OTHER } +] + +/** A client whose only capability is the Person lookup the member list performs. */ +function personClient (persons: unknown[] = PERSONS): { client: TxOperations, queries: Array> } { + const queries: Array> = [] + const client = { + findAll: async (_cls: string, query: Record) => { + queries.push(query) + return persons + } + } + return { client: client as unknown as TxOperations, queries } +} + +function context (accounts: AccountApi, client: TxOperations = personClient().client): ToolContext { + const session: WorkspaceSession = { + client, + accounts, + identity: fakeIdentity({ account: ME }), + markup: { read: async () => '' } + } + return toolContext(session, fakeMeasureContext()) +} + +const textOf = (result: { content: unknown[] }): string => (result.content[0] as { text: string }).text + +describe('workspace administration tools', () => { + it('reports settings together with the caller role', async () => { + const { accounts } = fakeAccounts() + + const result = await getWorkspaceTool.handler(context(accounts), {}) + + expect(JSON.parse(textOf(result))).toMatchObject({ + name: 'Acme', + url: 'acme', + allowReadOnlyGuests: true, + allowGuestSignUp: false, + yourRole: 'Owner' + }) + }) + + it('lists members with names, role names and a marker for the caller', async () => { + const { accounts } = fakeAccounts() + + const result = await listMembersTool.handler(context(accounts), {}) + + expect(JSON.parse(textOf(result)).members).toEqual([ + { accountId: ME, name: 'Grace Hopper', role: 'Owner', you: true }, + { accountId: OTHER, name: 'Alan Turing', role: 'User', you: false } + ]) + }) + + it('resolves names with one workspace query and leaves unknown members unnamed', async () => { + const { accounts } = fakeAccounts() + const { client, queries } = personClient([PERSONS[0]]) + + const result = await listMembersTool.handler(context(accounts, client), {}) + + expect(queries).toEqual([{ personUuid: { $in: [ME, OTHER] } }]) + expect(JSON.parse(textOf(result)).members[1]).toMatchObject({ accountId: OTHER, name: null }) + }) + + it('maps a role name to the account role when changing a member', async () => { + const { accounts, calls } = fakeAccounts() + + await setMemberRoleTool.handler(context(accounts), { accountId: OTHER, role: 'Maintainer' }) + + expect(calls).toContainEqual(['updateWorkspaceRole', [OTHER, AccountRole.Maintainer]]) + }) + + it('never offers the platform Admin role', () => { + const role = (setMemberRoleTool.inputSchema.properties as Record).role + expect(role.enum).toEqual(['ReadOnlyGuest', 'DocGuest', 'Guest', 'User', 'Maintainer', 'Owner']) + }) + + it('translates a Forbidden status into a message naming the action', async () => { + const { accounts } = fakeAccounts({ + updateWorkspaceRole: async () => { + throw forbidden() + } + }) + + const result = await setMemberRoleTool.handler(context(accounts), { accountId: OTHER, role: 'Owner' }) + + expect(result.isError).toBe(true) + expect(textOf(result)).toContain('does not allow you to change member roles') + }) + + it('lets unexpected errors propagate so the registry reports them', async () => { + const { accounts } = fakeAccounts({ + removeMember: async () => { + throw new Error('network down') + } + }) + + await expect(removeMemberTool.handler(context(accounts), { accountId: OTHER })).rejects.toThrow('network down') + }) + + it('removes a member, marks the tool destructive and invites with the User role by default', async () => { + const { accounts, calls } = fakeAccounts() + + await removeMemberTool.handler(context(accounts), { accountId: OTHER }) + await inviteMemberTool.handler(context(accounts), { email: 'new@example.test' }) + + expect(removeMemberTool.destructive).toBe(true) + expect(calls).toContainEqual(['removeMember', [OTHER]]) + expect(calls).toContainEqual(['sendInvite', ['new@example.test', AccountRole.User]]) + }) + + it('points at the link tool when the invitation email cannot be sent, but still reports Forbidden as a role problem', async () => { + const broken = fakeAccounts({ + sendInvite: async () => { + throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {})) + } + }) + const failed = await inviteMemberTool.handler(context(broken.accounts), { email: 'new@example.test' }) + expect(failed.isError).toBe(true) + expect(textOf(failed)).toContain('huly_create_invite_link') + + const denied = fakeAccounts({ + sendInvite: async () => { + throw forbidden() + } + }) + const refused = await inviteMemberTool.handler(context(denied.accounts), { email: 'new@example.test' }) + expect(textOf(refused)).toContain('does not allow you to invite members') + }) + + it('creates an invite link with the requested expiry', async () => { + const { accounts, calls } = fakeAccounts() + + const result = await createInviteLinkTool.handler(context(accounts), { + email: 'new@example.test', + role: 'Guest', + expiresInHours: 24 + }) + + expect(calls).toContainEqual(['createInviteLink', ['new@example.test', AccountRole.Guest, 24]]) + expect(JSON.parse(textOf(result)).link).toBe('https://huly.test/invite/abc') + }) + + it('changes only the guest settings that were passed and refuses an empty call', async () => { + const { accounts, calls } = fakeAccounts() + + await updateGuestSettingsTool.handler(context(accounts), { allowGuestSignUp: true }) + expect(calls.map(([name]) => name)).toEqual(['updateAllowGuestSignUp']) + + const empty = await updateGuestSettingsTool.handler(context(accounts), {}) + expect(textOf(empty)).toContain('Nothing to change') + expect(calls).toHaveLength(1) + }) + + it('renames the workspace', async () => { + const { accounts, calls } = fakeAccounts() + + await updateWorkspaceNameTool.handler(context(accounts), { name: 'Acme Inc' }) + + expect(calls).toContainEqual(['updateWorkspaceName', ['Acme Inc']]) + }) + + it('exposes only read tools as read-only', () => { + expect(accountTools.filter((tool) => tool.readOnly).map((tool) => tool.name)).toEqual([ + 'huly_get_workspace', + 'huly_list_members' + ]) + }) +}) diff --git a/pods/mcp/src/tools/__tests__/tools.test.ts b/pods/mcp/src/tools/__tests__/tools.test.ts index 68a78f5fa7..ea86a61a1b 100644 --- a/pods/mcp/src/tools/__tests__/tools.test.ts +++ b/pods/mcp/src/tools/__tests__/tools.test.ts @@ -78,6 +78,7 @@ function scriptedClient (script: Script): { client: TxOperations, recorded: Reco function context (client: TxOperations, markupWriter?: MarkupWriter): ToolContext { const session: WorkspaceSession = { client, + accounts: Object.create(null) as WorkspaceSession['accounts'], identity: fakeIdentity(), markup: { read: async () => '' }, markupWriter diff --git a/pods/mcp/src/tools/account-tools.ts b/pods/mcp/src/tools/account-tools.ts new file mode 100644 index 0000000000..93456e1432 --- /dev/null +++ b/pods/mcp/src/tools/account-tools.ts @@ -0,0 +1,279 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import contact from '@hcengineering/contact' +import { AccountRole, type AccountUuid } from '@hcengineering/core' +import platform, { PlatformError } from '@hcengineering/platform' + +import { errorResult, textResult, type McpToolCallResult } from '../mcp/protocol' +import { booleanProp, numberProp, objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool, type ToolContext } from '../mcp/tool' +import { toIso } from './shared' + +/** + * Workspace administration: settings, members and roles. + * + * These go to the account service, not the workspace transactor, and run with + * the caller's own token. The service enforces the role rules (for example a + * maintainer cannot remove an owner), so a refusal is reported as a plain + * sentence the agent can relay instead of an opaque platform status. + */ + +/** Roles an agent may assign. `Admin` is the platform operator role and is never grantable here. */ +const ASSIGNABLE_ROLES = Object.keys(AccountRole).filter((name) => name !== 'Admin') + +const roleFromName = (name: string): AccountRole => AccountRole[name as keyof typeof AccountRole] + +const roleName = (role: AccountRole): string => + Object.entries(AccountRole).find(([, value]) => value === role)?.[0] ?? String(role) + +const ROLE_PROP = { + type: 'string' as const, + enum: ASSIGNABLE_ROLES +} + +/** Maximum member names resolved in one call; members past this are listed by id only. */ +const MAX_NAMED_MEMBERS = 200 + +/** Runs an account call, turning a Forbidden status into an answer the agent can act on. */ +async function asCaller (action: string, run: () => Promise): Promise { + try { + return await run() + } catch (err) { + if (err instanceof PlatformError && err.status.code === platform.status.Forbidden) { + return errorResult( + `Your role in this workspace does not allow you to ${action}. Call huly_get_workspace to see your role.` + ) + } + throw err + } +} + +async function callerRole (ctx: ToolContext): Promise { + const members = await ctx.accounts.getWorkspaceMembers() + const me = members.find((member) => member.person === ctx.account) + return me === undefined ? null : roleName(me.role) +} + +export const getWorkspaceTool: HulyTool = { + name: 'huly_get_workspace', + title: 'Get workspace settings', + description: + 'Show this workspace\'s settings (name, url, region, guest access) and the caller\'s own role in it. ' + + 'Call this first to learn what the caller is allowed to change.', + readOnly: true, + inputSchema: objectSchema({}), + handler: async (ctx) => { + const [info, role] = await Promise.all([ctx.accounts.getWorkspaceInfo(), callerRole(ctx)]) + const result = { + id: info.uuid, + name: info.name, + url: info.url, + region: info.region ?? null, + createdOn: toIso(info.createdOn), + allowReadOnlyGuests: info.allowReadOnlyGuest ?? false, + allowGuestSignUp: info.allowGuestSignUp ?? false, + yourRole: role + } + return textResult(JSON.stringify(result, null, 2), result) + } +} + +export const listMembersTool: HulyTool = { + name: 'huly_list_members', + title: 'List workspace members', + description: + 'List everyone with access to this workspace, with their role. ' + + 'The accountId is what huly_set_member_role and huly_remove_member expect. ' + + 'A name can be null for a member who has just joined and has no profile in the workspace yet.', + readOnly: true, + inputSchema: objectSchema({}), + handler: async (ctx) => { + const members = await ctx.accounts.getWorkspaceMembers() + + // Names live on the workspace's own Person records, which link to the + // account through personUuid. The account service's person lookup is + // service-only, so a user token cannot use it. + const query: Record = { personUuid: { $in: members.map((member) => member.person) } } + const persons = (await ctx.client.findAll(contact.class.Person, query as never, { + limit: MAX_NAMED_MEMBERS + })) as unknown as Array<{ name: string, personUuid?: string }> + const names = new Map(persons.map((person) => [person.personUuid, person.name])) + + const rows = members.map((member) => ({ + accountId: member.person, + name: names.get(member.person) ?? null, + role: roleName(member.role), + you: member.person === ctx.account + })) + return textResult(JSON.stringify({ members: rows }, null, 2), { members: rows }) + } +} + +export const updateWorkspaceNameTool: HulyTool = { + name: 'huly_update_workspace_name', + title: 'Rename workspace', + description: 'Change the display name of the workspace. The workspace url does not change.', + readOnly: false, + inputSchema: objectSchema({ name: stringProp('New workspace name.', { minLength: 1, maxLength: 100 }) }, ['name']), + handler: async (ctx, args) => + await asCaller('rename the workspace', async () => { + await ctx.accounts.updateWorkspaceName(args.name as string) + return textResult(`Renamed the workspace to "${String(args.name)}".`, { updated: true }) + }) +} + +export const updateGuestSettingsTool: HulyTool = { + name: 'huly_update_workspace_guest_settings', + title: 'Change guest access', + description: + 'Turn read-only guest access and guest sign-up on or off for the workspace. Only the fields you pass change.', + readOnly: false, + inputSchema: objectSchema({ + allowReadOnlyGuests: booleanProp('Let people without an account view the workspace read-only.'), + allowGuestSignUp: booleanProp('Let guests sign up for an account on their own.') + }), + handler: async (ctx, args) => { + if (args.allowReadOnlyGuests === undefined && args.allowGuestSignUp === undefined) { + return textResult('Nothing to change. Pass allowReadOnlyGuests and/or allowGuestSignUp.', { updated: false }) + } + return await asCaller('change guest access', async () => { + const changed: string[] = [] + if (args.allowReadOnlyGuests !== undefined) { + await ctx.accounts.updateAllowReadOnlyGuests(args.allowReadOnlyGuests as boolean) + changed.push('allowReadOnlyGuests') + } + if (args.allowGuestSignUp !== undefined) { + await ctx.accounts.updateAllowGuestSignUp(args.allowGuestSignUp as boolean) + changed.push('allowGuestSignUp') + } + return textResult(`Updated guest access: ${changed.join(', ')}.`, { updated: true, changed }) + }) + } +} + +export const setMemberRoleTool: HulyTool = { + name: 'huly_set_member_role', + title: 'Change a member\'s role', + description: + 'Change the role of a workspace member. Get the accountId from huly_list_members. ' + + 'The account service decides who may grant which role.', + readOnly: false, + inputSchema: objectSchema( + { + accountId: stringProp('Member account id from huly_list_members.'), + role: { ...ROLE_PROP, description: 'New role.' } + }, + ['accountId', 'role'] + ), + handler: async (ctx, args) => + await asCaller('change member roles', async () => { + const role = roleFromName(args.role as string) + await ctx.accounts.updateWorkspaceRole(args.accountId as AccountUuid, role) + return textResult(`Set the role of ${String(args.accountId)} to ${String(args.role)}.`, { + updated: true, + role: args.role + }) + }) +} + +export const removeMemberTool: HulyTool = { + name: 'huly_remove_member', + title: 'Remove a member', + description: + 'Remove a member from the workspace. Their account is not deleted, they just lose access here. ' + + 'Get the accountId from huly_list_members. The last owner cannot be removed.', + readOnly: false, + destructive: true, + inputSchema: objectSchema({ accountId: stringProp('Member account id from huly_list_members.') }, ['accountId']), + handler: async (ctx, args) => + await asCaller('remove members', async () => { + await ctx.accounts.removeMember(args.accountId as AccountUuid) + return textResult(`Removed ${String(args.accountId)} from the workspace.`, { removed: true }) + }) +} + +export const inviteMemberTool: HulyTool = { + name: 'huly_invite_member', + title: 'Invite a member by email', + description: + 'Send an email invitation to join the workspace with the given role. This sends a real email. ' + + 'To get a link instead, use huly_create_invite_link.', + readOnly: false, + inputSchema: objectSchema( + { + email: stringProp('Email address to invite.', { minLength: 3, maxLength: 320 }), + role: { ...ROLE_PROP, description: 'Role the invitee gets. Defaults to User.', default: 'User' } + }, + ['email'] + ), + handler: async (ctx, args) => + await asCaller('invite members', async () => { + const role = (args.role as string | undefined) ?? 'User' + try { + await ctx.accounts.sendInvite(args.email as string, roleFromName(role)) + } catch (err) { + // A Forbidden still means "your role", so leave that to asCaller. Anything else at this + // point is almost always the account service failing to send mail. + if (err instanceof PlatformError && err.status.code === platform.status.Forbidden) throw err + return errorResult( + `The invitation to ${String(args.email)} could not be sent. The account service may have no email ` + + 'service configured. Use huly_create_invite_link to get a link to share instead.' + ) + } + return textResult(`Invited ${String(args.email)} as ${role}.`, { invited: true }) + }) +} + +export const createInviteLinkTool: HulyTool = { + name: 'huly_create_invite_link', + title: 'Create an invite link', + description: + 'Create a link that lets one email address join the workspace with the given role. ' + + 'Anyone holding the link can use it until it expires, so treat it as a credential.', + readOnly: false, + inputSchema: objectSchema( + { + email: stringProp('Email address the link is for.', { minLength: 3, maxLength: 320 }), + role: { ...ROLE_PROP, description: 'Role the invitee gets. Defaults to User.', default: 'User' }, + expiresInHours: numberProp('Hours until the link expires (1-720, default 48).', { + minimum: 1, + maximum: 720, + default: 48 + }) + }, + ['email'] + ), + handler: async (ctx, args) => + await asCaller('create invite links', async () => { + const role = (args.role as string | undefined) ?? 'User' + const hours = typeof args.expiresInHours === 'number' ? args.expiresInHours : 48 + const link = await ctx.accounts.createInviteLink(args.email as string, roleFromName(role), hours) + return textResult(JSON.stringify({ link, email: args.email, role, expiresInHours: hours }, null, 2), { + created: true + }) + }) +} + +export const accountTools: HulyTool[] = [ + getWorkspaceTool, + listMembersTool, + updateWorkspaceNameTool, + updateGuestSettingsTool, + setMemberRoleTool, + removeMemberTool, + inviteMemberTool, + createInviteLinkTool +] diff --git a/pods/mcp/src/tools/register.ts b/pods/mcp/src/tools/register.ts index 94a56dc777..ab489ad745 100644 --- a/pods/mcp/src/tools/register.ts +++ b/pods/mcp/src/tools/register.ts @@ -14,6 +14,7 @@ */ import { ToolRegistry } from '../mcp/tool' +import { accountTools } from './account-tools' import { componentTools } from './component-tools' import { documentTools } from './document-tools' import { issueTools } from './issue-tools' @@ -31,6 +32,7 @@ import { searchTools } from './search-tool' export function buildRegistry (): ToolRegistry { return new ToolRegistry().registerAll([ ...searchTools, + ...accountTools, ...projectTools, ...issueTools, ...componentTools, From 0afa447eea51e4664372cb4492c11a6a0d17886c Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:27:24 +0530 Subject: [PATCH 07/32] feat(mcp): add generic read tools over any workspace class - 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> --- pods/mcp/README.md | 5 + .../src/tools/__tests__/model-tools.test.ts | 248 +++++++++++++ pods/mcp/src/tools/model-tools.ts | 336 ++++++++++++++++++ pods/mcp/src/tools/register.ts | 6 +- 4 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 pods/mcp/src/tools/__tests__/model-tools.test.ts create mode 100644 pods/mcp/src/tools/model-tools.ts diff --git a/pods/mcp/README.md b/pods/mcp/README.md index fac9fd7796..2382203f4f 100644 --- a/pods/mcp/README.md +++ b/pods/mcp/README.md @@ -120,6 +120,11 @@ Read: - `huly_list_spaces`, `huly_list_drives`, `huly_list_documents`, `huly_get_document` - `huly_list_milestones`, `huly_list_components` - `huly_get_workspace` (settings and the caller's own role), `huly_list_members` +- Generic access to any class, for everything the named tools do not cover (templates, space types, roles, + custom fields, other apps): `huly_list_classes` (discover what exists), `huly_describe_class` (fields and + types), `huly_find` (query with filters, sort, projection and a total count), `huly_get_doc` (one document, + with rich-text fields returned as Markdown). They run as the caller, so the transactor decides which + documents come back: a user who is not in a project sees none of its issues. Write (refused when read-only): diff --git a/pods/mcp/src/tools/__tests__/model-tools.test.ts b/pods/mcp/src/tools/__tests__/model-tools.test.ts new file mode 100644 index 0000000000..d2ee3318bf --- /dev/null +++ b/pods/mcp/src/tools/__tests__/model-tools.test.ts @@ -0,0 +1,248 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import core, { type Hierarchy, type TxOperations } from '@hcengineering/core' + +import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' +import { toolContext, type ToolContext } from '../../mcp/tool' +import { type WorkspaceSession } from '../../platform/workspace-client-provider' +import { describeClassTool, findTool, getDocTool, listClassesTool } from '../model-tools' + +type Row = Record + +const ISSUE = 'tracker:class:Issue' +const SPACE = 'core:class:Space' +const ABSTRACT = 'core:class:Abstract' + +const CLASSES: Record = { + [core.class.Doc]: {}, + [SPACE]: { extends: core.class.Doc, domain: 'space' }, + [ISSUE]: { extends: core.class.Doc, domain: 'task' }, + 'tracker:mixin:Extra': { extends: ISSUE, domain: 'task', mixin: true }, + [ABSTRACT]: { extends: core.class.Doc } +} + +const ATTRIBUTES = new Map([ + ['title', { name: 'title', type: { _class: 'core:class:TypeString' }, required: true }], + ['assignee', { name: 'assignee', type: { _class: 'core:class:RefTo', to: 'contact:class:Person' } }], + ['labels', { name: 'labels', type: { _class: 'core:class:Collection', of: 'tags:class:TagReference' } }], + ['tags', { name: 'tags', type: { _class: 'core:class:ArrOf', of: { _class: 'core:class:TypeString' } } }], + ['description', { name: 'description', type: { _class: core.class.TypeCollaborativeDoc } }], + ['notes', { name: 'notes', type: { _class: core.class.TypeMarkup } }], + ['size', { name: 'size', type: { _class: 'core:class:TypeNumber' }, isCustom: true }], + ['secretField', { name: 'secretField', type: { _class: 'core:class:TypeString' }, hidden: true }] +]) + +const fakeHierarchy = (): Hierarchy => { + const hierarchy = { + hasClass: (id: string) => id in CLASSES, + findClass: (id: string) => (id in CLASSES ? { _id: id, extends: CLASSES[id].extends } : undefined), + findDomain: (id: string) => { + let current: string | undefined = id + while (current !== undefined && current in CLASSES) { + if (CLASSES[current].domain !== undefined) return CLASSES[current].domain + current = CLASSES[current].extends + } + return undefined + }, + isMixin: (id: string) => CLASSES[id]?.mixin === true, + getDescendants: (id: string) => Object.keys(CLASSES).filter((key) => key !== id && (id === core.class.Doc || CLASSES[key].extends === id)), + getAllAttributes: () => ATTRIBUTES + } + return hierarchy as unknown as Hierarchy +} + +interface Recorded { + findAll: Array<{ cls: string, query: Row, options: Row }> +} + +function scripted (docs: Row[] = [], total?: number, one?: Row): { client: TxOperations, recorded: Recorded } { + const recorded: Recorded = { findAll: [] } + const client = { + getHierarchy: fakeHierarchy, + findAll: async (cls: string, query: Row, options: Row) => { + recorded.findAll.push({ cls, query, options }) + return Object.assign([...docs], { total: total ?? docs.length }) + }, + findOne: async () => one + } + return { client: client as unknown as TxOperations, recorded } +} + +function context (client: TxOperations, read: (ref: string) => Promise = async () => ''): ToolContext { + const session: WorkspaceSession = { + client, + accounts: Object.create(null) as WorkspaceSession['accounts'], + identity: fakeIdentity(), + markup: { read } + } + return toolContext(session, fakeMeasureContext()) +} + +const payload = (result: { content: unknown[] }): any => JSON.parse((result.content[0] as { text: string }).text) +const textOf = (result: { content: unknown[] }): string => (result.content[0] as { text: string }).text + +describe('huly_list_classes', () => { + it('lists only queryable classes by default and reports the total', async () => { + const result = await listClassesTool.handler(context(scripted().client), {}) + + const ids = payload(result).classes.map((row: Row) => row.id) + expect(ids).toEqual([SPACE, ISSUE, 'tracker:mixin:Extra'].sort()) + expect(ids).not.toContain(ABSTRACT) + expect(ids).not.toContain(core.class.Doc) + }) + + it('filters by search, kind and ancestor, and can include abstract classes', async () => { + const ctx = context(scripted().client) + + const bySearch = payload(await listClassesTool.handler(ctx, { search: 'TRACKER' })) + expect(bySearch.classes.map((row: Row) => row.id)).toEqual([ISSUE, 'tracker:mixin:Extra']) + + const mixins = payload(await listClassesTool.handler(ctx, { kind: 'mixin' })) + expect(mixins.classes.map((row: Row) => row.id)).toEqual(['tracker:mixin:Extra']) + + const withAbstract = payload(await listClassesTool.handler(ctx, { includeAbstract: true, search: 'abstract' })) + expect(withAbstract.classes[0]).toMatchObject({ id: ABSTRACT, queryable: false, domain: null }) + + const derived = payload(await listClassesTool.handler(ctx, { extends: ISSUE })) + expect(derived.classes.map((row: Row) => row.id)).toEqual([ISSUE, 'tracker:mixin:Extra']) + }) + + it('caps the page and still reports the full match count', async () => { + const result = payload(await listClassesTool.handler(context(scripted().client), { limit: 2 })) + + expect(result.returned).toBe(2) + expect(result.total).toBe(3) + }) + + it('explains an unknown extends class', async () => { + const result = await listClassesTool.handler(context(scripted().client), { extends: 'nope:class:Nope' }) + + expect(textOf(result)).toContain('not a class in this workspace') + }) +}) + +describe('huly_describe_class', () => { + it('describes field types, separates collections, flags custom fields and hides hidden ones', async () => { + const result = payload(await describeClassTool.handler(context(scripted().client), { classId: ISSUE })) + + expect(result).toMatchObject({ id: ISSUE, kind: 'class', extends: core.class.Doc, domain: 'task', queryable: true }) + const byName = Object.fromEntries(result.fields.map((field: Row) => [field.name, field])) + expect(byName.title).toMatchObject({ type: 'String', required: true, custom: false }) + expect(byName.assignee).toMatchObject({ type: 'Ref', to: 'contact:class:Person' }) + expect(byName.tags).toMatchObject({ type: 'Array', of: { type: 'String' } }) + expect(byName.size).toMatchObject({ custom: true }) + expect(byName.secretField).toBeUndefined() + expect(result.collections).toEqual([{ name: 'labels', of: 'tags:class:TagReference' }]) + }) + + it('points at the discovery tool for an unknown class', async () => { + const result = await describeClassTool.handler(context(scripted().client), { classId: 'nope:class:Nope' }) + + expect(textOf(result)).toContain('huly_list_classes') + }) +}) + +describe('huly_find', () => { + const docs = [ + { _id: 'a', _class: ISSUE, title: 'One', extra: 1 }, + { _id: 'b', _class: ISSUE, title: 'Two', extra: 2 } + ] + + it('passes the query through, sorts by modification time by default and reports the total', async () => { + const { client, recorded } = scripted(docs, 42) + + const result = payload(await findTool.handler(context(client), { classId: ISSUE, query: { space: 's1' }, limit: 2 })) + + expect(recorded.findAll[0]).toMatchObject({ + cls: ISSUE, + query: { space: 's1' }, + options: { limit: 2, sort: { modifiedOn: -1 }, total: true } + }) + expect(result).toMatchObject({ total: 42, returned: 2 }) + }) + + it('honours an explicit sort direction', async () => { + const { client, recorded } = scripted(docs) + + await findTool.handler(context(client), { classId: ISSUE, sortBy: 'title', ascending: true }) + + expect(recorded.findAll[0].options.sort).toEqual({ title: 1 }) + }) + + it('projects fields but always keeps the id and class', async () => { + const result = payload(await findTool.handler(context(scripted(docs).client), { classId: ISSUE, fields: ['title'] })) + + expect(result.documents[0]).toEqual({ _id: 'a', _class: ISSUE, title: 'One' }) + }) + + it('refuses an abstract or unknown class without querying', async () => { + const { client, recorded } = scripted(docs) + + const abstract = await findTool.handler(context(client), { classId: ABSTRACT }) + const unknown = await findTool.handler(context(client), { classId: 'nope:class:Nope' }) + + expect(textOf(abstract)).toContain('abstract class') + expect(textOf(unknown)).toContain('huly_list_classes') + expect(recorded.findAll).toHaveLength(0) + }) + + it('cuts an oversized result to fit and says so', async () => { + const big = Array.from({ length: 40 }, (_, index) => ({ _id: String(index), _class: ISSUE, body: 'x'.repeat(5000) })) + + const result = payload(await findTool.handler(context(scripted(big).client), { classId: ISSUE })) + + expect(result.truncated).toBe(true) + expect(result.returned).toBeLessThan(40) + expect(result.total).toBe(40) + }) + + it('suggests checking field names when nothing matches', async () => { + const result = await findTool.handler(context(scripted([]).client), { classId: ISSUE, query: { nope: 1 } }) + + expect(textOf(result)).toContain('huly_describe_class') + }) +}) + +describe('huly_get_doc', () => { + const inline = JSON.stringify({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Inline body' }] }] }) + const doc = { _id: 'a', _class: ISSUE, title: 'One', description: 'blob-ref-1', notes: inline, size: 3 } + + it('resolves blob references and inline markup to Markdown and leaves other fields alone', async () => { + const read = jest.fn(async (ref: string) => (ref === 'blob-ref-1' ? 'Blob body' : '')) + + const result = await getDocTool.handler(context(scripted([], 0, doc).client, read), { classId: ISSUE, id: 'a' }) + + expect(payload(result)).toMatchObject({ title: 'One', description: 'Blob body', notes: 'Inline body', size: 3 }) + expect(read).toHaveBeenCalledTimes(1) + }) + + it('reports a missing document without throwing', async () => { + const result = await getDocTool.handler(context(scripted([], 0, undefined).client), { classId: ISSUE, id: 'zzz' }) + + expect(textOf(result)).toContain('No tracker:class:Issue with id zzz') + }) + + it('skips empty rich-text fields', async () => { + const read = jest.fn(async () => 'never') + + await getDocTool.handler(context(scripted([], 0, { ...doc, description: '', notes: '' }).client, read), { + classId: ISSUE, + id: 'a' + }) + + expect(read).not.toHaveBeenCalled() + }) +}) diff --git a/pods/mcp/src/tools/model-tools.ts b/pods/mcp/src/tools/model-tools.ts new file mode 100644 index 0000000000..4be21741b5 --- /dev/null +++ b/pods/mcp/src/tools/model-tools.ts @@ -0,0 +1,336 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import core, { type AnyAttribute, type Class, type Doc, type Hierarchy, type Ref, SortingOrder } from '@hcengineering/core' + +import { textResult } from '../mcp/protocol' +import { booleanProp, objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool, type ToolContext } from '../mcp/tool' +import { fromMarkup } from '../platform/markup' +import { truncate } from '../platform/markup-reader' +import { clampLimit } from './shared' + +/** + * Generic, read-only access to any class in the workspace model. + * + * The curated tools cover the common screens. These four let an agent reach + * everything else (templates, space types, custom fields, other apps) by first + * discovering what exists. They run as the caller through the workspace + * client, so the transactor decides which documents come back. + */ + +/** Ceiling on the serialised size of one tool result, to protect the model's context window. */ +const MAX_RESULT_CHARS = 60_000 +/** Ceiling on one resolved rich-text field. */ +const MAX_MARKUP_CHARS = 20_000 + +/** Attributes every document carries; they are not listed per class. */ +const BASE_FIELDS = ['_id', '_class', 'space', 'modifiedOn', 'modifiedBy', 'createdOn', 'createdBy'] + +const RICH_TEXT_TYPES: string[] = [core.class.TypeCollaborativeDoc, core.class.TypeMarkup] + +const classKind = (hierarchy: Hierarchy, id: Ref>): 'mixin' | 'class' => + hierarchy.isMixin(id) ? 'mixin' : 'class' + +/** Domain of a class, or undefined when it is abstract and so cannot be queried. */ +function domainOf (hierarchy: Hierarchy, id: string): string | undefined { + return hierarchy.findDomain(id as Ref>) +} + +/** Refuses ids that are not queryable classes, with a pointer to the discovery tool. */ +function queryableError (hierarchy: Hierarchy, id: string): string | undefined { + if (!hierarchy.hasClass(id as Ref>)) { + return `"${id}" is not a class in this workspace. Call huly_list_classes to find the right id.` + } + if (domainOf(hierarchy, id) === undefined) { + return `"${id}" is an abstract class and cannot be queried directly. Call huly_list_classes with extends="${id}" to see the classes derived from it.` + } + return undefined +} + +/** Model type classes whose own names read poorly to an agent. */ +const TYPE_NAMES: Record = { RefTo: 'Ref', ArrOf: 'Array', EnumOf: 'Enum' } + +interface TypeDescription { + type: string + to?: string + of?: string | TypeDescription +} + +/** Reduces a model `Type` to a short, readable description. */ +function describeType (type: { _class: string, to?: string, of?: unknown }): TypeDescription { + const name = type._class.split(':').pop() ?? type._class + const kind = TYPE_NAMES[name] ?? (name.startsWith('Type') ? name.slice(4) : name) + const result: TypeDescription = { type: kind } + if (typeof type.to === 'string') result.to = type.to + if (typeof type.of === 'string') result.of = type.of + else if (typeof type.of === 'object' && type.of !== null) result.of = describeType(type.of as never) + return result +} + +export const listClassesTool: HulyTool = { + name: 'huly_list_classes', + title: 'List model classes', + description: + 'Discover what kinds of objects exist in this workspace. Returns class ids you can pass to ' + + 'huly_describe_class, huly_find and huly_get_doc. Narrow with search (substring of the id) or ' + + 'extends (a parent class id, e.g. "core:class:Space" for every kind of space). ' + + 'By default only classes that can be queried are listed.', + readOnly: true, + inputSchema: objectSchema({ + search: stringProp('Case-insensitive substring of the class id, e.g. "template" or "tracker".'), + extends: stringProp('Only classes derived from this class id. Defaults to core:class:Doc (all documents).'), + kind: { type: 'string', description: 'Restrict to classes or to mixins.', enum: ['class', 'mixin'] }, + includeAbstract: booleanProp('Also list abstract classes that cannot be queried. Defaults to false.'), + limit: { type: 'integer', description: 'Maximum classes to return (1-200, default 100).', default: 100 } + }), + handler: async (ctx, args) => { + const hierarchy = ctx.client.getHierarchy() + const root = (args.extends as string | undefined) ?? core.class.Doc + + if (!hierarchy.hasClass(root as Ref>)) { + return textResult(`"${root}" is not a class in this workspace.`, { classes: [] }) + } + + const needle = (args.search as string | undefined)?.toLowerCase() + const limit = clampLimit(args.limit, 100) + + const candidates = new Set([root, ...hierarchy.getDescendants(root as Ref>)]) + const rows: Array> = [] + let matched = 0 + + for (const id of [...candidates].sort()) { + const klass = hierarchy.findClass(id as Ref>) + if (klass === undefined) continue + if (needle !== undefined && !id.toLowerCase().includes(needle)) continue + const kind = classKind(hierarchy, id as Ref>) + if (args.kind !== undefined && args.kind !== kind) continue + const domain = domainOf(hierarchy, id) + if (domain === undefined && args.includeAbstract !== true) continue + + matched++ + if (rows.length < limit) { + rows.push({ id, kind, extends: klass.extends ?? null, queryable: domain !== undefined, domain: domain ?? null }) + } + } + + if (rows.length === 0) { + return textResult('No classes matched. Try a shorter search or a different extends.', { classes: [] }) + } + + const payload = { total: matched, returned: rows.length, classes: rows } + return textResult(JSON.stringify(payload, null, 2), payload) + } +} + +export const describeClassTool: HulyTool = { + name: 'huly_describe_class', + title: 'Describe a class', + description: + 'Show the fields of a class: each field\'s name and type (a Ref field names the class it points to), ' + + 'whether it is required, and whether it is a custom field added by a user. Also lists the parent ' + + 'classes and collections of attached documents. Every document also has ' + + BASE_FIELDS.join(', ') + + '.', + readOnly: true, + inputSchema: objectSchema({ classId: stringProp('Class id from huly_list_classes.') }, ['classId']), + handler: async (ctx, args) => { + const hierarchy = ctx.client.getHierarchy() + const id = args.classId as string + const ref = id as Ref> + + if (!hierarchy.hasClass(ref)) { + return textResult(`"${id}" is not a class in this workspace. Call huly_list_classes to find the right id.`, { + found: false + }) + } + + let attributes: Map + try { + attributes = hierarchy.getAllAttributes(ref, core.class.Doc) + } catch { + attributes = hierarchy.getAllAttributes(ref) + } + + const fields: Array> = [] + const collections: Array> = [] + + for (const [name, attribute] of attributes) { + if (attribute.hidden === true) continue + const description = describeType(attribute.type as never) + if (description.type === 'Collection') { + collections.push({ name, of: description.of }) + continue + } + fields.push({ + name, + ...description, + required: attribute.required === true, + custom: attribute.isCustom === true + }) + } + + const klass = hierarchy.findClass(ref) + const payload = { + id, + kind: classKind(hierarchy, ref), + extends: klass?.extends ?? null, + domain: domainOf(hierarchy, id) ?? null, + queryable: domainOf(hierarchy, id) !== undefined, + fields, + collections + } + return textResult(JSON.stringify(payload, null, 2), { found: true, ...payload }) + } +} + +/** Serialises rows, dropping from the end until the result fits the size ceiling. */ +function fitRows (rows: Array>): { rows: Array>, truncated: boolean } { + let kept = rows + while (kept.length > 1 && JSON.stringify(kept).length > MAX_RESULT_CHARS) { + kept = kept.slice(0, Math.ceil(kept.length / 2)) + } + return { rows: kept, truncated: kept.length < rows.length } +} + +function project (doc: Record, fields: string[] | undefined): Record { + if (fields === undefined) return doc + const picked: Record = {} + for (const name of ['_id', '_class', ...fields]) { + if (name in doc) picked[name] = doc[name] + } + return picked +} + +export const findTool: HulyTool = { + name: 'huly_find', + title: 'Find documents of any class', + description: + 'Query documents of any class. query is a filter object of field: value pairs; a value can be an ' + + 'operator object such as {"$in": [...]}, {"$ne": x}, {"$gt": n}, {"$like": "%text%"}. ' + + 'Get class ids from huly_list_classes and field names from huly_describe_class. ' + + 'Use fields to return only some columns. Returns at most 200 documents and reports the total match count. ' + + 'Rich-text fields come back as stored references; use huly_get_doc to read them as text.', + readOnly: true, + inputSchema: objectSchema( + { + classId: stringProp('Class id from huly_list_classes.'), + query: { type: 'object', description: 'Filter, e.g. {"space": "", "title": {"$like": "%bug%"}}. Omit for all.' }, + fields: { + type: 'array', + description: 'Only return these fields (plus _id and _class).', + items: { type: 'string' }, + maxItems: 50 + }, + sortBy: stringProp('Field to sort by. Defaults to most recently modified first.'), + ascending: booleanProp('Sort ascending instead of descending.'), + limit: { type: 'integer', description: 'Maximum documents to return (1-200, default 50).', default: 50 } + }, + ['classId'] + ), + handler: async (ctx, args) => { + const hierarchy = ctx.client.getHierarchy() + const id = args.classId as string + + const problem = queryableError(hierarchy, id) + if (problem !== undefined) return textResult(problem, { found: false }) + + const query = (args.query as Record | undefined) ?? {} + const sortField = (args.sortBy as string | undefined) ?? 'modifiedOn' + const descending = args.sortBy === undefined ? true : args.ascending !== true + + const found = await ctx.client.findAll(id as Ref>, query as never, { + limit: clampLimit(args.limit), + sort: { [sortField]: descending ? SortingOrder.Descending : SortingOrder.Ascending }, + total: true + }) + + if (found.length === 0) { + return textResult('No documents matched. Check the field names with huly_describe_class.', { + total: 0, + documents: [] + }) + } + + const fields = args.fields as string[] | undefined + const projected = (found as unknown as Array>).map((doc) => project(doc, fields)) + const { rows, truncated } = fitRows(projected) + + const payload = { + total: found.total >= 0 ? found.total : found.length, + returned: rows.length, + ...(truncated ? { truncated: true, note: 'Output was cut to fit; narrow the query or pass fields.' } : {}), + documents: rows + } + return textResult(JSON.stringify(payload, null, 2), { + total: payload.total, + returned: rows.length, + truncated + }) + } +} + +/** Resolves one rich-text value (inline markup or a blob reference) to Markdown. */ +async function readRichText (ctx: ToolContext, value: string): Promise { + const text = value.trimStart().startsWith('{') ? fromMarkup(value) : await ctx.markup.read(value) + return truncate(text.trim(), MAX_MARKUP_CHARS) +} + +export const getDocTool: HulyTool = { + name: 'huly_get_doc', + title: 'Get one document of any class', + description: + 'Fetch one document by id with every field, and read its rich-text fields (descriptions, page bodies) ' + + 'as Markdown instead of stored references. Get the class id and document id from huly_find.', + readOnly: true, + inputSchema: objectSchema( + { classId: stringProp('Class id of the document.'), id: stringProp('Document id.') }, + ['classId', 'id'] + ), + handler: async (ctx, args) => { + const hierarchy = ctx.client.getHierarchy() + const classId = args.classId as string + + const problem = queryableError(hierarchy, classId) + if (problem !== undefined) return textResult(problem, { found: false }) + + const query: Record = { _id: args.id } + const doc = (await ctx.client.findOne(classId as Ref>, query as never)) as unknown as + | Record + | undefined + + if (doc === undefined) { + return textResult( + `No ${classId} with id ${String(args.id)} is visible to you. It may not exist or be in a space you cannot access.`, + { found: false } + ) + } + + const attributes = hierarchy.getAllAttributes((doc._class ?? classId) as Ref>, core.class.Doc) + const resolved: string[] = [] + const result: Record = { ...doc } + + for (const [name, attribute] of attributes) { + const value = doc[name] + if (!RICH_TEXT_TYPES.includes(attribute.type._class) || typeof value !== 'string' || value === '') continue + result[name] = await readRichText(ctx, value) + resolved.push(name) + } + + return textResult(JSON.stringify(result, null, 2), { found: true, id: doc._id, richTextFields: resolved }) + } +} + +export const modelTools: HulyTool[] = [listClassesTool, describeClassTool, findTool, getDocTool] diff --git a/pods/mcp/src/tools/register.ts b/pods/mcp/src/tools/register.ts index ab489ad745..eb861742fb 100644 --- a/pods/mcp/src/tools/register.ts +++ b/pods/mcp/src/tools/register.ts @@ -18,6 +18,7 @@ import { accountTools } from './account-tools' import { componentTools } from './component-tools' import { documentTools } from './document-tools' import { issueTools } from './issue-tools' +import { modelTools } from './model-tools' import { personTools } from './people-tools' import { projectTools } from './project-tools' import { searchTools } from './search-tool' @@ -37,7 +38,8 @@ export function buildRegistry (): ToolRegistry { ...issueTools, ...componentTools, ...personTools, - ...documentTools + ...documentTools, + ...modelTools ]) } @@ -55,6 +57,8 @@ export const MCP_INSTRUCTIONS = [ ' before huly_update_issue.', '- Sub-issues are created with huly_create_issue and parentIssueId; components come from huly_list_components.', '- People are referenced by id. Call huly_find_people to resolve a name to an id.', + '- For anything the named tools do not cover, discover it: huly_list_classes -> huly_describe_class ->', + ' huly_find / huly_get_doc. These can read any class the caller is allowed to see.', '- Tools that modify data are refused when the server runs in read-only mode.', '', 'Ids are opaque strings. Always pass an id obtained from a list or search tool rather than', From bfbf720c15db9d421d10fae89fe4e798a62a8c64 Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:25:31 +0530 Subject: [PATCH 08/32] feat(mcp): write tools for documents and other profile-backed classes 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> --- pods/mcp/README.md | 13 + pods/mcp/package.json | 2 + pods/mcp/src/platform/collaborator-writer.ts | 10 +- pods/mcp/src/platform/markup-reader.ts | 2 + .../__tests__/document-write-tools.test.ts | 205 ++++++++++ .../__tests__/generic-write-tools.test.ts | 361 ++++++++++++++++++ pods/mcp/src/tools/__tests__/tools.test.ts | 3 +- pods/mcp/src/tools/caller-rights.ts | 64 ++++ pods/mcp/src/tools/document-write-tools.ts | 163 ++++++++ pods/mcp/src/tools/generic-write-tools.ts | 275 +++++++++++++ pods/mcp/src/tools/model-tools.ts | 4 +- pods/mcp/src/tools/register.ts | 9 +- pods/mcp/src/tools/write-profiles.ts | 318 +++++++++++++++ 13 files changed, 1422 insertions(+), 7 deletions(-) create mode 100644 pods/mcp/src/tools/__tests__/document-write-tools.test.ts create mode 100644 pods/mcp/src/tools/__tests__/generic-write-tools.test.ts create mode 100644 pods/mcp/src/tools/caller-rights.ts create mode 100644 pods/mcp/src/tools/document-write-tools.ts create mode 100644 pods/mcp/src/tools/generic-write-tools.ts create mode 100644 pods/mcp/src/tools/write-profiles.ts diff --git a/pods/mcp/README.md b/pods/mcp/README.md index 2382203f4f..0a26a20527 100644 --- a/pods/mcp/README.md +++ b/pods/mcp/README.md @@ -130,12 +130,25 @@ Write (refused when read-only): - `huly_create_issue` (optionally as a sub-issue via `parentIssueId`, with a `componentId`), `huly_update_issue`, `huly_add_issue_comment` - `huly_create_milestone`, `huly_update_milestone`, `huly_create_component`, `huly_create_person` +- Documents: `huly_create_document` (a page in a teamspace, optionally nested, body as Markdown) and + `huly_update_document` (title and/or replace the body). +- Generic writes for a verified set of classes: `huly_create_doc`, `huly_update_doc`, `huly_delete_doc`. Supported: + components, milestones, issue templates, labels and document teamspaces (create, update, delete), tracker + projects (update only: rename, archive, members and owners), plus delete for issues and documents. Counters, + defaults, ranks and rich text are filled in as the web app does, and any field outside a class's allowed list + is refused. `huly_update_doc` also takes `push`/`pull` to add or remove one member or owner. Other classes + stay readable through `huly_find` but are not writable yet. - Workspace administration, enforced by the account service against the caller's role: `huly_update_workspace_name`, `huly_update_workspace_guest_settings`, `huly_set_member_role`, `huly_remove_member`, `huly_invite_member`, `huly_create_invite_link`. A refusal comes back as a plain sentence naming the action the role does not allow. ## Security notes +- **The generic write tools mirror the web app's ownership rules.** The transactor lets any member write to a public + space, so on its own it would let a plain user delete or rename a teamspace somebody else owns. The tools apply the + web app's rule on top: only a workspace owner or the creator may delete a document, and only a workspace owner, a + space owner or the creator may rename a space or change its members. + - **Write access is the caller's, not the server's.** Every write goes through `TxOperations`, which is permission-checked by the transactor. The pod never uses the system account, so a tool can never exceed the caller's rights. diff --git a/pods/mcp/package.json b/pods/mcp/package.json index 29a1bbffb8..0d8fdffbe2 100644 --- a/pods/mcp/package.json +++ b/pods/mcp/package.json @@ -67,8 +67,10 @@ "@hcengineering/document": "workspace:^0.7.0", "@hcengineering/drive": "workspace:^0.7.0", "@hcengineering/platform": "workspace:^0.7.20", + "@hcengineering/rank": "workspace:^0.7.18", "@hcengineering/server-core": "workspace:^0.7.19", "@hcengineering/server-token": "workspace:^0.7.18", + "@hcengineering/tags": "workspace:^0.7.0", "@hcengineering/task": "workspace:^0.7.0", "@hcengineering/text-core": "workspace:^0.7.19", "@hcengineering/text-markdown": "workspace:^0.7.21", diff --git a/pods/mcp/src/platform/collaborator-writer.ts b/pods/mcp/src/platform/collaborator-writer.ts index 59267d8eac..c51f2cc4f5 100644 --- a/pods/mcp/src/platform/collaborator-writer.ts +++ b/pods/mcp/src/platform/collaborator-writer.ts @@ -33,10 +33,14 @@ export function createCollaboratorWriter ( return (identity) => { const client = getCollaboratorClient(identity.workspace, identity.workspaceToken, collaboratorUrl) + const collabIdOf = (objectClass: string, objectId: string, attribute: string): ReturnType => + makeCollabId(objectClass as Ref>, objectId as Ref, attribute) + return { - write: async (objectClass, objectId, attribute, markdown) => { - const collabId = makeCollabId(objectClass as Ref>, objectId as Ref, attribute) - return await client.createMarkup(collabId, toMarkup(markdown)) + write: async (objectClass, objectId, attribute, markdown) => + await client.createMarkup(collabIdOf(objectClass, objectId, attribute), toMarkup(markdown)), + update: async (objectClass, objectId, attribute, markdown) => { + await client.updateMarkup(collabIdOf(objectClass, objectId, attribute), toMarkup(markdown)) } } } diff --git a/pods/mcp/src/platform/markup-reader.ts b/pods/mcp/src/platform/markup-reader.ts index 4e474670e1..aa15a71e59 100644 --- a/pods/mcp/src/platform/markup-reader.ts +++ b/pods/mcp/src/platform/markup-reader.ts @@ -30,6 +30,8 @@ export interface MarkupReader { */ export interface MarkupWriter { write: (objectClass: string, objectId: string, attribute: string, markdown: string) => Promise + /** Replaces the text behind an existing blob reference; the reference itself does not change. */ + update: (objectClass: string, objectId: string, attribute: string, markdown: string) => Promise } /** Reads a bounded number of characters, appending a marker when it truncates. */ diff --git a/pods/mcp/src/tools/__tests__/document-write-tools.test.ts b/pods/mcp/src/tools/__tests__/document-write-tools.test.ts new file mode 100644 index 0000000000..c512596e87 --- /dev/null +++ b/pods/mcp/src/tools/__tests__/document-write-tools.test.ts @@ -0,0 +1,205 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { type TxOperations } from '@hcengineering/core' +import document from '@hcengineering/document' +import { makeRank } from '@hcengineering/rank' + +import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' +import { toolContext, type ToolContext } from '../../mcp/tool' +import { type AccountApi } from '../../platform/account-api' +import { type MarkupWriter } from '../../platform/markup-reader' +import { type WorkspaceSession } from '../../platform/workspace-client-provider' +import { createDocumentTool, updateDocumentTool } from '../document-write-tools' + +type Row = Record + +interface Recorded { + createDoc: Array<{ cls: string, space: string, values: Row, id: string }> + updateDoc: Array<{ id: string, ops: Row }> + findOne: Array<{ cls: string, query: Row, options?: Row }> +} + +interface Script { + teamspace?: Row + parent?: Row + lastSibling?: Row + page?: Row +} + +function setup (script: Script, writer?: MarkupWriter): { ctx: ToolContext, recorded: Recorded } { + const recorded: Recorded = { createDoc: [], updateDoc: [], findOne: [] } + const client = { + findOne: async (cls: string, query: Row, options?: Row) => { + recorded.findOne.push({ cls, query, options }) + if (cls === document.class.Teamspace) return script.teamspace + // The parent lookup filters by _id, the sibling lookup sorts by rank, the page lookup is by _id alone. + if (options?.sort !== undefined) return script.lastSibling + if (query.space !== undefined) return script.parent + return script.page + }, + createDoc: async (cls: string, space: string, values: Row, id: string) => { + recorded.createDoc.push({ cls, space, values, id }) + }, + updateDoc: async (_cls: string, _space: string, id: string, ops: Row) => { + recorded.updateDoc.push({ id, ops }) + } + } + const session: WorkspaceSession = { + client: client as unknown as TxOperations, + accounts: Object.create(null) as AccountApi, + identity: fakeIdentity(), + markup: { read: async () => '' }, + markupWriter: writer + } + return { ctx: toolContext(session, fakeMeasureContext()), recorded } +} + +function fakeWriter (): { writer: MarkupWriter, writes: string[][], updates: string[][] } { + const writes: string[][] = [] + const updates: string[][] = [] + const writer: MarkupWriter = { + write: async (cls, id, attribute, markdown) => { + writes.push([cls, id, attribute, markdown]) + return `blob-${id}` + }, + update: async (cls, id, attribute, markdown) => { + updates.push([cls, id, attribute, markdown]) + } + } + return { writer, writes, updates } +} + +const textOf = (result: { content: unknown[] }): string => (result.content[0] as { text: string }).text +const TEAMSPACE = { _id: 'ts-1' } + +describe('huly_create_document', () => { + it('creates a top-level page after its siblings with zeroed counters', async () => { + const { ctx, recorded } = setup({ teamspace: TEAMSPACE, lastSibling: { rank: '0|hzzzzz:' } }) + + const result = await createDocumentTool.handler(ctx, { teamspaceId: 'ts-1', title: 'Handbook' }) + + expect(recorded.createDoc).toHaveLength(1) + const { cls, space, values, id } = recorded.createDoc[0] + expect(cls).toBe(document.class.Document) + expect(space).toBe('ts-1') + expect(values).toMatchObject({ + title: 'Handbook', + content: null, + parent: document.ids.NoParent, + attachments: 0, + embeddings: 0, + labels: 0, + comments: 0, + references: 0 + }) + expect(String(values.rank) > '0|hzzzzz:').toBe(true) + expect(values.rank).toBe(makeRank('0|hzzzzz:', undefined)) + expect(JSON.parse(textOf(result)).id).toBe(id) + }) + + it('ranks the first page of an empty teamspace without a previous rank', async () => { + const { ctx, recorded } = setup({ teamspace: TEAMSPACE }) + + await createDocumentTool.handler(ctx, { teamspaceId: 'ts-1', title: 'First' }) + + expect(recorded.createDoc[0].values.rank).toBe(makeRank(undefined, undefined)) + }) + + it('stores the body through the writer and keeps the returned reference', async () => { + const { writer, writes } = fakeWriter() + const { ctx, recorded } = setup({ teamspace: TEAMSPACE }, writer) + + await createDocumentTool.handler(ctx, { teamspaceId: 'ts-1', title: 'Page', content: '# Hi\n\nBody' }) + + const { id, values } = recorded.createDoc[0] + expect(writes).toEqual([[document.class.Document, id, 'content', '# Hi\n\nBody']]) + expect(values.content).toBe(`blob-${id}`) + }) + + it('refuses a body without a writer before creating anything', async () => { + const { ctx, recorded } = setup({ teamspace: TEAMSPACE }) + + const result = await createDocumentTool.handler(ctx, { teamspaceId: 'ts-1', title: 'Page', content: 'Body' }) + + expect(textOf(result)).toContain('COLLABORATOR_URL') + expect(recorded.createDoc).toHaveLength(0) + }) + + it('nests under a parent in the same teamspace and refuses an unknown parent or teamspace', async () => { + const nested = setup({ teamspace: TEAMSPACE, parent: { _id: 'p1' } }) + await createDocumentTool.handler(nested.ctx, { teamspaceId: 'ts-1', title: 'Child', parentDocumentId: 'p1' }) + expect(nested.recorded.createDoc[0].values.parent).toBe('p1') + expect(nested.recorded.findOne.find((call) => call.query._id === 'p1')?.query).toEqual({ _id: 'p1', space: 'ts-1' }) + + const orphan = setup({ teamspace: TEAMSPACE }) + const refusedParent = await createDocumentTool.handler(orphan.ctx, { teamspaceId: 'ts-1', title: 'x', parentDocumentId: 'nope' }) + expect(textOf(refusedParent)).toContain('No page with id nope exists in teamspace ts-1') + + const noSpace = setup({}) + const refusedSpace = await createDocumentTool.handler(noSpace.ctx, { teamspaceId: 'zzz', title: 'x' }) + expect(textOf(refusedSpace)).toContain('No teamspace with id zzz') + + expect(orphan.recorded.createDoc).toHaveLength(0) + expect(noSpace.recorded.createDoc).toHaveLength(0) + }) +}) + +describe('huly_update_document', () => { + it('replaces existing text in place and leaves the reference alone', async () => { + const { writer, writes, updates } = fakeWriter() + const { ctx, recorded } = setup({ page: { _id: 'd1', space: 'ts-1', content: 'blob-old' } }, writer) + + const result = await updateDocumentTool.handler(ctx, { documentId: 'd1', content: 'New body' }) + + expect(updates).toEqual([[document.class.Document, 'd1', 'content', 'New body']]) + expect(writes).toHaveLength(0) + expect(recorded.updateDoc).toHaveLength(0) + expect(textOf(result)).toContain('content') + }) + + it('writes a first body into an empty page and stores the new reference with the title', async () => { + const { writer, writes, updates } = fakeWriter() + const { ctx, recorded } = setup({ page: { _id: 'd1', space: 'ts-1', content: null } }, writer) + + await updateDocumentTool.handler(ctx, { documentId: 'd1', content: 'First text', title: 'Renamed' }) + + expect(writes).toEqual([[document.class.Document, 'd1', 'content', 'First text']]) + expect(updates).toHaveLength(0) + expect(recorded.updateDoc).toEqual([{ id: 'd1', ops: { title: 'Renamed', content: 'blob-d1' } }]) + }) + + it('changes only the title without touching the collaborator', async () => { + const { writer, writes, updates } = fakeWriter() + const { ctx, recorded } = setup({ page: { _id: 'd1', space: 'ts-1', content: 'blob-old' } }, writer) + + await updateDocumentTool.handler(ctx, { documentId: 'd1', title: 'Only title' }) + + expect(recorded.updateDoc).toEqual([{ id: 'd1', ops: { title: 'Only title' } }]) + expect(writes).toHaveLength(0) + expect(updates).toHaveLength(0) + }) + + it('refuses an empty call, a missing page and content without a writer', async () => { + const page = { _id: 'd1', space: 'ts-1', content: 'blob-old' } + + expect(textOf(await updateDocumentTool.handler(setup({ page }).ctx, { documentId: 'd1' }))).toContain('Nothing to change') + expect(textOf(await updateDocumentTool.handler(setup({}).ctx, { documentId: 'zzz', title: 'x' }))).toContain('No document with id zzz') + + const noWriter = setup({ page }) + expect(textOf(await updateDocumentTool.handler(noWriter.ctx, { documentId: 'd1', content: 'x' }))).toContain('COLLABORATOR_URL') + expect(noWriter.recorded.updateDoc).toHaveLength(0) + }) +}) diff --git a/pods/mcp/src/tools/__tests__/generic-write-tools.test.ts b/pods/mcp/src/tools/__tests__/generic-write-tools.test.ts new file mode 100644 index 0000000000..bef87c775b --- /dev/null +++ b/pods/mcp/src/tools/__tests__/generic-write-tools.test.ts @@ -0,0 +1,361 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import contact from '@hcengineering/contact' +import core, { AccountRole, type AccountUuid, type Hierarchy, type TxOperations } from '@hcengineering/core' +import document from '@hcengineering/document' +import tags from '@hcengineering/tags' +import task from '@hcengineering/task' +import tracker from '@hcengineering/tracker' + +import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' +import { toolContext, type ToolContext } from '../../mcp/tool' +import { type AccountApi } from '../../platform/account-api' +import { type WorkspaceSession } from '../../platform/workspace-client-provider' +import { createDocTool, deleteDocTool, updateDocTool } from '../generic-write-tools' + +type Row = Record + +const ME = 'account-1' as AccountUuid +const PROJECT = 'proj-1' + +const str = { _class: 'core:class:TypeString' } +const markup = { _class: core.class.TypeMarkup } +const ref = { _class: 'core:class:RefTo', to: 'x' } +const date = { _class: 'core:class:TypeDate' } +const bool = { _class: 'core:class:TypeBoolean' } +const arr = { _class: 'core:class:ArrOf', of: str } +const num = { _class: 'core:class:TypeNumber' } +const attrs = (entries: Record): Map => new Map(Object.entries(entries)) + +const ATTRIBUTES: Record> = { + [tracker.class.Component]: attrs({ label: { type: str }, description: { type: markup }, lead: { type: ref } }), + [tracker.class.Milestone]: attrs({ + label: { type: str }, + description: { type: markup }, + status: { type: { _class: 'tracker:class:TypeMilestoneStatus' } }, + startDate: { type: date }, + targetDate: { type: date } + }), + [tracker.class.IssueTemplate]: attrs({ + title: { type: str }, + description: { type: markup }, + priority: { type: { _class: 'tracker:class:TypeIssuePriority' } }, + assignee: { type: ref }, + component: { type: ref }, + milestone: { type: ref }, + estimation: { type: num }, + labels: { type: arr } + }), + [tags.class.TagElement]: attrs({ title: { type: str }, description: { type: str }, color: { type: num }, targetClass: { type: ref }, category: { type: ref } }), + [document.class.Teamspace]: attrs({ + name: { type: str }, + description: { type: str }, + private: { type: bool }, + archived: { type: bool }, + members: { type: arr }, + owners: { type: arr }, + autoJoin: { type: bool } + }), + [tracker.class.Project]: attrs({ name: { type: str }, description: { type: str }, archived: { type: bool }, members: { type: arr }, owners: { type: arr } }) +} + +const DERIVED: Record = { + [tracker.class.Project]: [core.class.Space, tracker.class.Project], + [document.class.Teamspace]: [core.class.Space, document.class.Teamspace] +} + +const hierarchy = (): Hierarchy => + ({ + getAllAttributes: (id: string) => ATTRIBUTES[id] ?? new Map(), + isDerived: (id: string, from: string) => id === from || (DERIVED[id] ?? []).includes(from) + }) as unknown as Hierarchy + +interface Calls { + createDoc: Array<{ cls: string, space: string, values: Row, id: string }> + updateDoc: Array<{ cls: string, space: string, id: string, ops: Row }> + removeDoc: Array<[string, string, string]> + removeCollection: unknown[][] +} + +interface Script { + findOne?: Record + findAll?: Record + role?: AccountRole +} + +function setup (script: Script = {}): { ctx: ToolContext, calls: Calls } { + const calls: Calls = { createDoc: [], updateDoc: [], removeDoc: [], removeCollection: [] } + const client = { + getHierarchy: hierarchy, + findOne: async (cls: string) => script.findOne?.[cls], + findAll: async (cls: string) => script.findAll?.[cls] ?? [], + createDoc: async (cls: string, space: string, values: Row, id: string) => { + calls.createDoc.push({ cls, space, values, id }) + }, + updateDoc: async (cls: string, space: string, id: string, ops: Row) => { + calls.updateDoc.push({ cls, space, id, ops }) + }, + removeDoc: async (cls: string, space: string, id: string) => { + calls.removeDoc.push([cls, space, id]) + }, + removeCollection: async (...args: unknown[]) => { + calls.removeCollection.push(args) + } + } + const accounts = { + getWorkspaceMembers: async () => [{ person: ME, role: script.role ?? AccountRole.User }] + } + const session: WorkspaceSession = { + client: client as unknown as TxOperations, + accounts: accounts as unknown as AccountApi, + identity: fakeIdentity({ account: ME }), + markup: { read: async () => '' } + } + return { ctx: toolContext(session, fakeMeasureContext()), calls } +} + +const textOf = (result: { content: unknown[] }): string => (result.content[0] as { text: string }).text +const projectSpace = { _id: PROJECT, _class: tracker.class.Project } + +describe('huly_create_doc', () => { + it('refuses a class without a create profile and lists what is supported', async () => { + const { ctx, calls } = setup() + + const result = await createDocTool.handler(ctx, { classId: 'contact:class:Person', data: {} }) + + expect(textOf(result)).toContain('cannot be created') + expect(textOf(result)).toContain(tracker.class.Component) + expect(calls.createDoc).toHaveLength(0) + }) + + it('refuses fields outside the profile, unknown fields and missing required fields', async () => { + const { ctx, calls } = setup({ findOne: { [core.class.Space]: projectSpace } }) + + const counter = await createDocTool.handler(ctx, { classId: tracker.class.Component, spaceId: PROJECT, data: { label: 'x', comments: 9 } }) + const missing = await createDocTool.handler(ctx, { classId: tracker.class.Component, spaceId: PROJECT, data: {} }) + const wrongType = await createDocTool.handler(ctx, { classId: tracker.class.Component, spaceId: PROJECT, data: { label: 5 } }) + + expect(textOf(counter)).toContain('"comments" cannot be set') + expect(textOf(missing)).toContain('Missing required field(s) for tracker:class:Component: label') + expect(textOf(wrongType)).toContain('"label" must be a string') + expect(calls.createDoc).toHaveLength(0) + }) + + it('needs a space of the right class for project-scoped classes', async () => { + const noSpace = setup() + expect(textOf(await createDocTool.handler(noSpace.ctx, { classId: tracker.class.Component, data: { label: 'x' } }))).toContain('spaceId is required') + + const missing = setup() + expect(textOf(await createDocTool.handler(missing.ctx, { classId: tracker.class.Component, spaceId: 'nope', data: { label: 'x' } }))).toContain('No space with id nope') + + const wrongClass = setup({ findOne: { [core.class.Space]: { _id: 'w', _class: core.class.Space } } }) + const refused = await createDocTool.handler(wrongClass.ctx, { classId: tracker.class.Component, spaceId: 'w', data: { label: 'x' } }) + expect(textOf(refused)).toContain('can only be created in a tracker:class:Project') + expect(wrongClass.calls.createDoc).toHaveLength(0) + }) + + it('creates a component with defaults, rich text converted and the generated id returned', async () => { + const { ctx, calls } = setup({ findOne: { [core.class.Space]: projectSpace } }) + + const result = await createDocTool.handler(ctx, { + classId: tracker.class.Component, + spaceId: PROJECT, + data: { label: 'Backend', description: 'Core **services**' } + }) + + expect(calls.createDoc).toHaveLength(1) + const [call] = calls.createDoc + expect(call).toMatchObject({ cls: tracker.class.Component, space: PROJECT }) + expect(call.values).toMatchObject({ label: 'Backend', lead: null, comments: 0, attachments: 0 }) + expect(String(call.values.description)).toContain('"type":"doc"') + expect(JSON.parse(textOf(result)).id).toBe(call.id) + }) + + it('converts milestone status names and ISO dates to their stored form', async () => { + const { ctx, calls } = setup({ findOne: { [core.class.Space]: projectSpace } }) + + await createDocTool.handler(ctx, { + classId: tracker.class.Milestone, + spaceId: PROJECT, + data: { label: 'Beta', status: 'InProgress', targetDate: '2026-12-31T00:00:00Z' } + }) + const bad = await createDocTool.handler(ctx, { classId: tracker.class.Milestone, spaceId: PROJECT, data: { label: 'x', status: 'Done' } }) + + expect(calls.createDoc[0].values).toMatchObject({ status: 1, targetDate: Date.parse('2026-12-31T00:00:00Z'), startDate: null }) + expect(textOf(bad)).toContain('"status" must be one of: Planned, InProgress, Completed, Canceled') + }) + + it('derives the kind of an issue template from the project task type, or refuses without one', async () => { + const withType = setup({ + findOne: { [core.class.Space]: projectSpace, [tracker.class.Project]: { type: 'pt' }, [task.class.TaskType]: { _id: 'kind-1' } } + }) + await createDocTool.handler(withType.ctx, { classId: tracker.class.IssueTemplate, spaceId: PROJECT, data: { title: 'Bug', priority: 'High' } }) + expect(withType.calls.createDoc[0].values).toMatchObject({ kind: 'kind-1', priority: 2, comments: 0 }) + + const withoutType = setup({ findOne: { [core.class.Space]: projectSpace, [tracker.class.Project]: { type: 'pt' } } }) + const refused = await createDocTool.handler(withoutType.ctx, { classId: tracker.class.IssueTemplate, spaceId: PROJECT, data: { title: 'Bug' } }) + expect(textOf(refused)).toContain('no issue task type') + expect(withoutType.calls.createDoc).toHaveLength(0) + }) + + it('creates a label in the workspace space and a teamspace with the creator as member and owner', async () => { + const { ctx, calls } = setup() + + await createDocTool.handler(ctx, { classId: tags.class.TagElement, data: { title: 'urgent' } }) + await createDocTool.handler(ctx, { classId: document.class.Teamspace, data: { name: 'Handbook' } }) + + expect(calls.createDoc[0]).toMatchObject({ cls: tags.class.TagElement, space: core.space.Workspace }) + expect(calls.createDoc[0].values).toMatchObject({ title: 'urgent', targetClass: tracker.class.Issue, category: tags.category.NoCategory }) + expect(typeof calls.createDoc[0].values.color).toBe('number') + expect(calls.createDoc[1]).toMatchObject({ cls: document.class.Teamspace, space: core.space.Space }) + expect(calls.createDoc[1].values).toMatchObject({ name: 'Handbook', members: [ME], owners: [ME], private: false }) + }) +}) + +describe('huly_update_doc', () => { + const component = { _id: 'c1', space: PROJECT } + + it('sets converted fields on an existing document', async () => { + const { ctx, calls } = setup({ findOne: { [tracker.class.Component]: component } }) + + await updateDocTool.handler(ctx, { classId: tracker.class.Component, id: 'c1', data: { label: 'Renamed', lead: null } }) + + expect(calls.updateDoc).toEqual([{ cls: tracker.class.Component, space: PROJECT, id: 'c1', ops: { label: 'Renamed', lead: null } }]) + }) + + it('refuses unsupported classes, read-only fields, empty updates and missing documents', async () => { + const { ctx, calls } = setup({ findOne: { [tracker.class.Component]: component } }) + + expect(textOf(await updateDocTool.handler(ctx, { classId: tracker.class.Issue, id: 'i', data: { title: 'x' } }))).toContain('cannot be updated') + expect(textOf(await updateDocTool.handler(ctx, { classId: tracker.class.Component, id: 'c1', data: { comments: 1 } }))).toContain('cannot be set') + expect(textOf(await updateDocTool.handler(ctx, { classId: tracker.class.Component, id: 'c1' }))).toContain('Nothing to change') + expect(textOf(await updateDocTool.handler(setup().ctx, { classId: tracker.class.Component, id: 'zzz', data: { label: 'x' } }))).toContain('No tracker:class:Component with id zzz') + expect(calls.updateDoc).toHaveLength(0) + }) + + it('adds and removes array items only on allowed fields', async () => { + const space = { _id: 's1', space: core.space.Space, owners: [ME] } + const { ctx, calls } = setup({ findOne: { [document.class.Teamspace]: space } }) + + await updateDocTool.handler(ctx, { classId: document.class.Teamspace, id: 's1', push: { members: 'a2' }, pull: { owners: 'a3' } }) + const bad = await updateDocTool.handler(ctx, { classId: document.class.Teamspace, id: 's1', push: { name: 'x' } }) + + expect(calls.updateDoc[0].ops).toEqual({ $push: { members: 'a2' }, $pull: { owners: 'a3' } }) + expect(textOf(bad)).toContain('"name" cannot be added to or removed from') + expect(calls.updateDoc).toHaveLength(1) + }) + + it('lets only a workspace owner, a space owner or the creator manage a space', async () => { + const someoneElses = { _id: 's1', space: core.space.Space, owners: ['other'], createdBy: 'not-me' } + const call = { classId: document.class.Teamspace, id: 's1', data: { name: 'New name' } } + + const plain = setup({ findOne: { [document.class.Teamspace]: someoneElses } }) + expect(textOf(await updateDocTool.handler(plain.ctx, call))).toContain('Only a workspace owner') + expect(plain.calls.updateDoc).toHaveLength(0) + + const owner = setup({ findOne: { [document.class.Teamspace]: someoneElses }, role: AccountRole.Owner }) + await updateDocTool.handler(owner.ctx, call) + expect(owner.calls.updateDoc).toHaveLength(1) + + const spaceOwner = setup({ findOne: { [document.class.Teamspace]: { ...someoneElses, owners: [ME] } } }) + await updateDocTool.handler(spaceOwner.ctx, call) + expect(spaceOwner.calls.updateDoc).toHaveLength(1) + + const creator = setup({ + findOne: { [document.class.Teamspace]: { ...someoneElses, createdBy: 'soc-1' }, [contact.class.Person]: { _id: 'p1' } }, + findAll: { [contact.class.SocialIdentity]: [{ _id: 'soc-1' }] } + }) + await updateDocTool.handler(creator.ctx, call) + expect(creator.calls.updateDoc).toHaveLength(1) + }) + + it('does not apply the space rule to ordinary documents', async () => { + const { ctx, calls } = setup({ findOne: { [tracker.class.Component]: { _id: 'c1', space: PROJECT, createdBy: 'not-me' } } }) + + await updateDocTool.handler(ctx, { classId: tracker.class.Component, id: 'c1', data: { label: 'x' } }) + + expect(calls.updateDoc).toHaveLength(1) + }) +}) + +describe('huly_delete_doc', () => { + const component = { _id: 'c1', space: PROJECT, createdBy: 'not-me' } + + it('refuses classes without a delete profile, including whole projects', async () => { + const { ctx, calls } = setup({ findOne: { [tracker.class.Project]: { _id: PROJECT, space: 's' } } }) + + const result = await deleteDocTool.handler(ctx, { classId: tracker.class.Project, id: PROJECT }) + + expect(textOf(result)).toContain('cannot be deleted') + expect(calls.removeDoc).toHaveLength(0) + }) + + it('reports a document that is not visible', async () => { + const { ctx, calls } = setup() + + const result = await deleteDocTool.handler(ctx, { classId: tracker.class.Component, id: 'zzz' }) + + expect(textOf(result)).toContain('No tracker:class:Component with id zzz') + expect(calls.removeDoc).toHaveLength(0) + }) + + it("applies the web app's rule: only a workspace owner or the creator may delete", async () => { + const plain = setup({ findOne: { [tracker.class.Component]: component } }) + expect(textOf(await deleteDocTool.handler(plain.ctx, { classId: tracker.class.Component, id: 'c1' }))).toContain('Only a workspace owner or the creator') + expect(plain.calls.removeDoc).toHaveLength(0) + + const owner = setup({ findOne: { [tracker.class.Component]: component }, role: AccountRole.Owner }) + await deleteDocTool.handler(owner.ctx, { classId: tracker.class.Component, id: 'c1' }) + expect(owner.calls.removeDoc).toEqual([[tracker.class.Component, PROJECT, 'c1']]) + + const creator = setup({ + findOne: { [tracker.class.Component]: { ...component, createdBy: 'soc-1' }, [contact.class.Person]: { _id: 'p1' } }, + findAll: { [contact.class.SocialIdentity]: [{ _id: 'soc-1' }] } + }) + await deleteDocTool.handler(creator.ctx, { classId: tracker.class.Component, id: 'c1' }) + expect(creator.calls.removeDoc).toHaveLength(1) + }) + + it('detaches issues from a milestone before deleting it', async () => { + const { ctx, calls } = setup({ + role: AccountRole.Owner, + findOne: { [tracker.class.Milestone]: { _id: 'm1', space: PROJECT } }, + findAll: { [tracker.class.Issue]: [{ _id: 'i1', space: PROJECT }, { _id: 'i2', space: PROJECT }] } + }) + + await deleteDocTool.handler(ctx, { classId: tracker.class.Milestone, id: 'm1' }) + + expect(calls.updateDoc.map((call) => [call.id, call.ops])).toEqual([ + ['i1', { milestone: null }], + ['i2', { milestone: null }] + ]) + expect(calls.removeDoc).toEqual([[tracker.class.Milestone, PROJECT, 'm1']]) + }) + + it('removes an issue through its parent collection', async () => { + const issue = { _id: 'i1', space: PROJECT, attachedTo: 'parent', attachedToClass: tracker.class.Issue, collection: 'subIssues' } + const { ctx, calls } = setup({ role: AccountRole.Owner, findOne: { [tracker.class.Issue]: issue } }) + + await deleteDocTool.handler(ctx, { classId: tracker.class.Issue, id: 'i1' }) + + expect(calls.removeDoc).toHaveLength(0) + expect(calls.removeCollection).toEqual([[tracker.class.Issue, PROJECT, 'i1', 'parent', tracker.class.Issue, 'subIssues']]) + }) + + it('is marked destructive', () => { + expect(deleteDocTool.destructive).toBe(true) + }) +}) diff --git a/pods/mcp/src/tools/__tests__/tools.test.ts b/pods/mcp/src/tools/__tests__/tools.test.ts index ea86a61a1b..a9806029a4 100644 --- a/pods/mcp/src/tools/__tests__/tools.test.ts +++ b/pods/mcp/src/tools/__tests__/tools.test.ts @@ -158,7 +158,8 @@ describe('huly_create_issue', () => { write: async (cls, id, attribute, markdown) => { written.push([cls, attribute, markdown]) return `blob-for-${id}` - } + }, + update: async () => {} } await createIssueTool.handler(context(client, writer), { projectId: 'proj-1', title: 'Hello', description: 'Body' }) diff --git a/pods/mcp/src/tools/caller-rights.ts b/pods/mcp/src/tools/caller-rights.ts new file mode 100644 index 0000000000..25c8b5f04d --- /dev/null +++ b/pods/mcp/src/tools/caller-rights.ts @@ -0,0 +1,64 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import contact from '@hcengineering/contact' +import { AccountRole, type Doc } from '@hcengineering/core' + +import { type ToolContext } from '../mcp/tool' + +/** + * Ownership rules the web app applies in the browser but the transactor does not. + * + * The transactor lets any workspace member write to a public space, so a plain + * user could delete or rename a teamspace someone else owns through the API even + * though the web app would refuse. The generic write tools mirror the web app's + * rule so an agent never has more reach than the person using the UI: only a + * workspace owner, or the creator of a document, may delete it. + */ + +/** True when the caller holds the workspace Owner role. */ +export async function callerIsOwner (ctx: ToolContext): Promise { + const members = await ctx.accounts.getWorkspaceMembers() + return members.some((member) => member.person === ctx.account && member.role === AccountRole.Owner) +} + +/** The caller's own social ids, which is what `createdBy` records. */ +async function callerSocialIds (ctx: ToolContext): Promise> { + const personQuery: Record = { personUuid: ctx.account } + const person = await ctx.client.findOne(contact.class.Person, personQuery as never) + if (person === undefined) return new Set() + + const identityQuery: Record = { attachedTo: person._id } + const identities = await ctx.client.findAll(contact.class.SocialIdentity, identityQuery as never) + return new Set(identities.map((identity) => identity._id as string)) +} + +async function callerCreated (ctx: ToolContext, doc: Doc): Promise { + return (await callerSocialIds(ctx)).has(doc.createdBy as string) +} + +/** The web app's delete rule: a workspace owner, or whoever created the document. */ +export async function mayDelete (ctx: ToolContext, doc: Doc): Promise { + return (await callerIsOwner(ctx)) || (await callerCreated(ctx, doc)) +} + +/** + * Who may rename a space, change its members or archive it: a workspace owner, + * an owner of that space, or its creator. + */ +export async function mayManageSpace (ctx: ToolContext, space: Doc & { owners?: string[] }): Promise { + if (space.owners?.includes(ctx.account) === true) return true + return (await callerIsOwner(ctx)) || (await callerCreated(ctx, space)) +} diff --git a/pods/mcp/src/tools/document-write-tools.ts b/pods/mcp/src/tools/document-write-tools.ts new file mode 100644 index 0000000000..242fd4c487 --- /dev/null +++ b/pods/mcp/src/tools/document-write-tools.ts @@ -0,0 +1,163 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { generateId, SortingOrder } from '@hcengineering/core' +import document, { type Document } from '@hcengineering/document' +import { makeRank } from '@hcengineering/rank' + +import { textResult } from '../mcp/protocol' +import { objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool } from '../mcp/tool' + +const NO_WRITER = + 'Document text was given but this server has no COLLABORATOR_URL configured, so nothing was changed. ' + + 'Retry without content, or ask the administrator to set COLLABORATOR_URL.' + +export const createDocumentTool: HulyTool = { + name: 'huly_create_document', + title: 'Create a document', + description: + 'Create a page in a document teamspace, optionally nested under another page. The content is Markdown. ' + + 'Get teamspace ids from huly_list_spaces and page ids from huly_list_documents. ' + + 'The new page is placed after its siblings.', + readOnly: false, + inputSchema: objectSchema( + { + teamspaceId: stringProp('Teamspace id from huly_list_spaces.'), + title: stringProp('Page title.', { minLength: 1, maxLength: 500 }), + content: stringProp('Page body. Markdown or plain text.', { maxLength: 500_000 }), + parentDocumentId: stringProp('Nest the page under this page id. Must be in the same teamspace.') + }, + ['teamspaceId', 'title'] + ), + handler: async (ctx, args) => { + const teamspaceId = args.teamspaceId as string + + const teamspaceQuery: Record = { _id: teamspaceId } + const teamspace = await ctx.client.findOne(document.class.Teamspace, teamspaceQuery as never) + if (teamspace === undefined) { + return textResult(`No teamspace with id ${teamspaceId} is visible to you, so nothing was created.`, { + created: false + }) + } + + let parent: string = document.ids.NoParent + if (args.parentDocumentId !== undefined) { + const parentQuery: Record = { _id: args.parentDocumentId, space: teamspaceId } + const found = await ctx.client.findOne(document.class.Document, parentQuery as never) + if (found === undefined) { + return textResult( + `No page with id ${String(args.parentDocumentId)} exists in teamspace ${teamspaceId}, so nothing was created.`, + { created: false } + ) + } + parent = args.parentDocumentId as string + } + + // Refuse before creating anything: a page without its text would look like success. + const content = ((args.content as string | undefined) ?? '').trim() + if (content !== '' && ctx.markupWriter === undefined) return textResult(NO_WRITER, { created: false }) + + const siblingQuery: Record = { space: teamspaceId, parent } + const last = await ctx.client.findOne(document.class.Document, siblingQuery as never, { + sort: { rank: SortingOrder.Descending }, + projection: { rank: 1 } + }) + const rank = makeRank(last?.rank, undefined) + + const id = generateId() + const contentRef = + content === '' || ctx.markupWriter === undefined + ? null + : await ctx.markupWriter.write(document.class.Document, id, 'content', content) + + const attributes: Record = { + title: args.title, + content: contentRef, + attachments: 0, + embeddings: 0, + labels: 0, + comments: 0, + references: 0, + rank, + parent + } + await ctx.client.createDoc(document.class.Document, teamspaceId as never, attributes as never, id) + + return textResult(JSON.stringify({ id, title: args.title, teamspaceId, parentDocumentId: parent }, null, 2), { + created: true, + documentId: id + }) + } +} + +export const updateDocumentTool: HulyTool = { + name: 'huly_update_document', + title: 'Edit a document', + description: + 'Change the title and/or replace the whole body of a page. Content is Markdown and REPLACES the existing text, ' + + 'so call huly_get_document first and send the full new body. Only the fields you pass change.', + readOnly: false, + inputSchema: objectSchema( + { + documentId: stringProp('Page id from huly_list_documents.'), + title: stringProp('New title.', { minLength: 1, maxLength: 500 }), + content: stringProp('New full body. Markdown or plain text.', { maxLength: 500_000 }) + }, + ['documentId'] + ), + handler: async (ctx, args) => { + const documentId = args.documentId as string + + if (args.title === undefined && args.content === undefined) { + return textResult('Nothing to change. Pass title and/or content.', { updated: false }) + } + + const query: Record = { _id: documentId } + const page = await ctx.client.findOne(document.class.Document, query as never) + if (page === undefined) { + return textResult(`No document with id ${documentId} is visible to you, so nothing was changed.`, { + updated: false + }) + } + + const changed: string[] = [] + const operations: Record = {} + if (args.title !== undefined) operations.title = args.title + + if (args.content !== undefined) { + if (ctx.markupWriter === undefined) return textResult(NO_WRITER, { updated: false }) + const content = args.content as string + if (page.content === null || page.content === undefined || page.content === '') { + operations.content = await ctx.markupWriter.write(document.class.Document, documentId, 'content', content) + } else { + await ctx.markupWriter.update(document.class.Document, documentId, 'content', content) + changed.push('content') + } + } + + if (Object.keys(operations).length > 0) { + await ctx.client.updateDoc(document.class.Document, page.space, page._id, operations as never) + changed.push(...Object.keys(operations)) + } + + return textResult(`Updated document ${documentId}: ${[...new Set(changed)].sort().join(', ')}.`, { + updated: true, + changed + }) + } +} + +export const documentWriteTools: HulyTool[] = [createDocumentTool, updateDocumentTool] diff --git a/pods/mcp/src/tools/generic-write-tools.ts b/pods/mcp/src/tools/generic-write-tools.ts new file mode 100644 index 0000000000..6df4d2a881 --- /dev/null +++ b/pods/mcp/src/tools/generic-write-tools.ts @@ -0,0 +1,275 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import core, { generateId, type Class, type Doc, type Ref } from '@hcengineering/core' + +import { textResult } from '../mcp/protocol' +import { objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool } from '../mcp/tool' +import { mayDelete, mayManageSpace } from './caller-rights' +import { + coerceFields, + creatableClasses, + profileFor, + removableClasses, + updatableClasses, + type WriteData, + type WriteProfile +} from './write-profiles' + +/** + * Generic create, update and delete for the classes that have a write profile. + * + * All three run as the caller through the workspace client, so the transactor + * still decides whether the caller may touch the target space or document. + */ + +const unsupported = (action: string, classId: string, supported: string[]): string => + `${classId} cannot be ${action} through the generic tools. Supported: ${supported.join(', ')}. ` + + 'Other classes can be read with huly_find but not written yet.' + +const isObject = (value: unknown): value is WriteData => + typeof value === 'object' && value !== null && !Array.isArray(value) + +export const createDocTool: HulyTool = { + name: 'huly_create_doc', + title: 'Create a document of a supported class', + description: + 'Create a component, milestone, issue template, label or document teamspace. data holds the fields ' + + '(see huly_describe_class for names and types); dates are ISO-8601, rich text is Markdown, and ' + + 'counters and defaults are filled in for you. spaceId is required for project-scoped classes ' + + '(component, milestone, issue template) and ignored for labels and teamspaces. ' + + 'Supported classes: ' + + creatableClasses().join(', ') + + '. Issues and documents have their own tools.', + readOnly: false, + inputSchema: objectSchema( + { + classId: stringProp('Class id to create.'), + spaceId: stringProp('Project id for project-scoped classes.'), + data: { type: 'object', description: 'Field values, e.g. {"label": "Backend"}.' } + }, + ['classId', 'data'] + ), + handler: async (ctx, args) => { + const classId = args.classId as string + const profile = profileFor(classId) + if (profile?.create === undefined) { + return textResult(unsupported('created', classId, creatableClasses()), { created: false }) + } + const create = profile.create + + const hierarchy = ctx.client.getHierarchy() + const coerced = coerceFields(hierarchy, profile, args.data as WriteData) + if (!coerced.ok) return textResult(coerced.error, { created: false }) + + const missing = (profile.required ?? []).filter((field) => coerced.data[field] === undefined) + if (missing.length > 0) { + return textResult(`Missing required field(s) for ${classId}: ${missing.join(', ')}.`, { created: false }) + } + + let spaceId: string + if (create.space === 'given') { + if (typeof args.spaceId !== 'string') { + return textResult(`spaceId is required to create a ${classId}. Use huly_list_projects to find one.`, { + created: false + }) + } + const query: Record = { _id: args.spaceId } + const space = (await ctx.client.findOne(core.class.Space, query as never)) as unknown as + | { _class: string } + | undefined + if (space === undefined) { + return textResult(`No space with id ${args.spaceId} is visible to you, so nothing was created.`, { + created: false + }) + } + if (create.spaceClass !== undefined && !hierarchy.isDerived(space._class as Ref>, create.spaceClass as Ref>)) { + return textResult(`${classId} can only be created in a ${create.spaceClass}; ${args.spaceId} is a ${space._class}.`, { + created: false + }) + } + spaceId = args.spaceId + } else { + spaceId = create.space + } + + let values: WriteData = { ...create.defaults(ctx, coerced.data), ...coerced.data } + if (create.prepare !== undefined) { + const prepared = await create.prepare(ctx, spaceId, values) + if (typeof prepared === 'string') return textResult(prepared, { created: false }) + values = { ...values, ...prepared } + } + + const id = generateId() + await ctx.client.createDoc(classId as Ref>, spaceId as never, values as never, id) + + return textResult(JSON.stringify({ id, classId, spaceId }, null, 2), { created: true, id }) + } +} + +/** Builds the `$push` / `$pull` operations, refusing fields the profile does not allow. */ +function arrayOperations ( + profile: WriteProfile, + push: WriteData | undefined, + pull: WriteData | undefined +): { ok: true, operations: WriteData } | { ok: false, error: string } { + const operations: WriteData = {} + for (const [key, source] of [['$push', push], ['$pull', pull]] as const) { + if (source === undefined) continue + for (const field of Object.keys(source)) { + if (!(profile.pushable ?? []).includes(field)) { + const pushable = profile.pushable ?? [] + const allowed = pushable.length === 0 ? 'none' : pushable.join(', ') + return { ok: false, error: `"${field}" cannot be added to or removed from on ${profile.classId}. Allowed: ${allowed}.` } + } + } + operations[key] = source + } + return { ok: true, operations } +} + +export const updateDocTool: HulyTool = { + name: 'huly_update_doc', + title: 'Update a document of a supported class', + description: + 'Change fields on a component, milestone, issue template, label, document teamspace or tracker project. ' + + 'data sets fields; push and pull add or remove one item from an array field such as members or owners ' + + '(e.g. push {"members": ""} to add a member to a project or teamspace). ' + + 'Only the fields you pass change. Supported classes: ' + + updatableClasses().join(', ') + + '. Issues and documents have their own update tools.', + readOnly: false, + inputSchema: objectSchema( + { + classId: stringProp('Class id of the document.'), + id: stringProp('Document id from huly_find.'), + data: { type: 'object', description: 'Fields to set.' }, + push: { type: 'object', description: 'Array field -> item to add, e.g. {"members": ""}.' }, + pull: { type: 'object', description: 'Array field -> item to remove.' } + }, + ['classId', 'id'] + ), + handler: async (ctx, args) => { + const classId = args.classId as string + const profile = profileFor(classId) + if (profile === undefined || profile.writable.length === 0) { + return textResult(unsupported('updated', classId, updatableClasses()), { updated: false }) + } + + const data = isObject(args.data) ? args.data : {} + const hierarchy = ctx.client.getHierarchy() + const coerced = coerceFields(hierarchy, profile, data) + if (!coerced.ok) return textResult(coerced.error, { updated: false }) + + const arrays = arrayOperations( + profile, + isObject(args.push) ? args.push : undefined, + isObject(args.pull) ? args.pull : undefined + ) + if (!arrays.ok) return textResult(arrays.error, { updated: false }) + + const operations: WriteData = { ...coerced.data, ...arrays.operations } + if (Object.keys(operations).length === 0) { + return textResult('Nothing to change. Pass data, push or pull.', { updated: false }) + } + + const query: Record = { _id: args.id } + const doc = (await ctx.client.findOne(classId as Ref>, query as never)) as unknown as Doc | undefined + if (doc === undefined) { + return textResult(`No ${classId} with id ${String(args.id)} is visible to you, so nothing was changed.`, { + updated: false + }) + } + + // The transactor lets any member write to a public space; the web app only lets its owners manage it. + if (hierarchy.isDerived(classId as Ref>, core.class.Space as Ref>) && !(await mayManageSpace(ctx, doc))) { + return textResult( + `Only a workspace owner, an owner of this ${profile.label.toLowerCase()}, or its creator may change it. ` + + 'Nothing was changed.', + { updated: false } + ) + } + + await ctx.client.updateDoc(classId as Ref>, doc.space as never, doc._id as never, operations as never) + + return textResult(`Updated ${classId} ${String(args.id)}: ${Object.keys(operations).sort().join(', ')}.`, { + updated: true, + changed: Object.keys(operations) + }) + } +} + +export const deleteDocTool: HulyTool = { + name: 'huly_delete_doc', + title: 'Delete a document of a supported class', + description: + 'Permanently delete an issue, document, component, milestone, issue template, label or document teamspace. ' + + 'This cannot be undone. Deleting a component or milestone detaches the issues that used it first. ' + + 'Supported classes: ' + + removableClasses().join(', ') + + '. A tracker project is archived with huly_update_doc {"archived": true} instead.', + readOnly: false, + destructive: true, + inputSchema: objectSchema({ classId: stringProp('Class id of the document.'), id: stringProp('Document id.') }, [ + 'classId', + 'id' + ]), + handler: async (ctx, args) => { + const classId = args.classId as string + const profile = profileFor(classId) + if (profile?.remove === undefined) { + return textResult(unsupported('deleted', classId, removableClasses()), { deleted: false }) + } + const remove = profile.remove + + const query: Record = { _id: args.id } + const doc = (await ctx.client.findOne(classId as Ref>, query as never)) as unknown as + | (Doc & { attachedTo?: string, attachedToClass?: string, collection?: string }) + | undefined + if (doc === undefined) { + return textResult(`No ${classId} with id ${String(args.id)} is visible to you, so nothing was deleted.`, { + deleted: false + }) + } + + // Same rule as the web app's delete action: a workspace owner, or the creator. + if (!(await mayDelete(ctx, doc))) { + return textResult( + `Only a workspace owner or the creator of this ${profile.label.toLowerCase()} may delete it. Nothing was deleted.`, + { deleted: false } + ) + } + + await remove.beforeRemove?.(ctx, doc) + + if (remove.attached === true) { + await ctx.client.removeCollection( + classId as Ref>, + doc.space as never, + doc._id as never, + doc.attachedTo as never, + doc.attachedToClass as never, + doc.collection as string + ) + } else { + await ctx.client.removeDoc(classId as Ref>, doc.space as never, doc._id as never) + } + + return textResult(`Deleted ${classId} ${String(args.id)}.`, { deleted: true }) + } +} + +export const genericWriteTools: HulyTool[] = [createDocTool, updateDocTool, deleteDocTool] diff --git a/pods/mcp/src/tools/model-tools.ts b/pods/mcp/src/tools/model-tools.ts index 4be21741b5..6b673bde56 100644 --- a/pods/mcp/src/tools/model-tools.ts +++ b/pods/mcp/src/tools/model-tools.ts @@ -63,14 +63,14 @@ function queryableError (hierarchy: Hierarchy, id: string): string | undefined { /** Model type classes whose own names read poorly to an agent. */ const TYPE_NAMES: Record = { RefTo: 'Ref', ArrOf: 'Array', EnumOf: 'Enum' } -interface TypeDescription { +export interface TypeDescription { type: string to?: string of?: string | TypeDescription } /** Reduces a model `Type` to a short, readable description. */ -function describeType (type: { _class: string, to?: string, of?: unknown }): TypeDescription { +export function describeType (type: { _class: string, to?: string, of?: unknown }): TypeDescription { const name = type._class.split(':').pop() ?? type._class const kind = TYPE_NAMES[name] ?? (name.startsWith('Type') ? name.slice(4) : name) const result: TypeDescription = { type: kind } diff --git a/pods/mcp/src/tools/register.ts b/pods/mcp/src/tools/register.ts index eb861742fb..679f374f42 100644 --- a/pods/mcp/src/tools/register.ts +++ b/pods/mcp/src/tools/register.ts @@ -17,6 +17,8 @@ import { ToolRegistry } from '../mcp/tool' import { accountTools } from './account-tools' import { componentTools } from './component-tools' import { documentTools } from './document-tools' +import { documentWriteTools } from './document-write-tools' +import { genericWriteTools } from './generic-write-tools' import { issueTools } from './issue-tools' import { modelTools } from './model-tools' import { personTools } from './people-tools' @@ -39,7 +41,9 @@ export function buildRegistry (): ToolRegistry { ...componentTools, ...personTools, ...documentTools, - ...modelTools + ...documentWriteTools, + ...modelTools, + ...genericWriteTools ]) } @@ -59,6 +63,9 @@ export const MCP_INSTRUCTIONS = [ '- People are referenced by id. Call huly_find_people to resolve a name to an id.', '- For anything the named tools do not cover, discover it: huly_list_classes -> huly_describe_class ->', ' huly_find / huly_get_doc. These can read any class the caller is allowed to see.', + '- Creating and editing pages: huly_create_document / huly_update_document. Other creates, updates and deletes:', + ' huly_create_doc / huly_update_doc / huly_delete_doc, which list the classes they support in their errors.', + ' Deletes are permanent; only a workspace owner or the creator of a document may delete it.', '- Tools that modify data are refused when the server runs in read-only mode.', '', 'Ids are opaque strings. Always pass an id obtained from a list or search tool rather than', diff --git a/pods/mcp/src/tools/write-profiles.ts b/pods/mcp/src/tools/write-profiles.ts new file mode 100644 index 0000000000..43b4ac05b4 --- /dev/null +++ b/pods/mcp/src/tools/write-profiles.ts @@ -0,0 +1,318 @@ +/** + Copyright © 2026 Intabia Fusion. + + Licensed under the Eclipse Public License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may + obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import core, { type AnyAttribute, type Class, type Doc, type Hierarchy, type Ref } from '@hcengineering/core' +import document from '@hcengineering/document' +import tags from '@hcengineering/tags' +import task from '@hcengineering/task' +import tracker, { IssuePriority, MilestoneStatus } from '@hcengineering/tracker' + +import { type ToolContext } from '../mcp/tool' +import { toMarkup } from '../platform/markup' +import { describeType } from './model-tools' +import { MILESTONE_STATUS_NAMES } from './shared' + +/** + * The classes the generic write tools may touch, and exactly how. + * + * 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 it has a + * profile here, and each profile was exercised against a live workspace. + * Fields outside `writable` are refused. + */ + +export type WriteData = Record + +export interface WriteProfile { + classId: string + label: string + /** Fields accepted on create and update. */ + writable: string[] + required?: string[] + /** Array fields huly_update_doc may push to or pull from. */ + pushable?: string[] + /** Numeric enums exposed to the agent by name. */ + enums?: Record + create?: { + /** `given` when the caller names the space, otherwise the fixed space id. */ + space: 'given' | string + /** Class the named space must belong to. */ + spaceClass?: string + defaults: (ctx: ToolContext, data: WriteData) => WriteData + /** Derives fields that need a lookup. Returns a message to refuse the create. */ + prepare?: (ctx: ToolContext, spaceId: string, data: WriteData) => Promise + } + remove?: { + /** Attached documents are removed through their parent's collection. */ + attached?: boolean + /** Clears references that would otherwise dangle. */ + beforeRemove?: (ctx: ToolContext, doc: Doc) => Promise + } +} + +const PRIORITY_NAMES = Object.keys(IssuePriority).filter((key) => Number.isNaN(Number(key))) +const DEFAULT_MILESTONE_SPAN_MS = 14 * 24 * 60 * 60 * 1000 + +/** Colour index for a new label, stable per title like the web client's text-derived colour. */ +const colorFor = (title: string): number => { + let sum = 0 + for (const char of title) sum = (sum * 31 + char.charCodeAt(0)) % 1000 + return sum % 20 +} + +const emptyMarkup = (): string => toMarkup('') + +/** Sets a reference field to null on every issue that points at the removed document. */ +async function detachFromIssues (ctx: ToolContext, field: 'milestone' | 'component', doc: Doc): Promise { + const query: Record = { [field]: doc._id } + const issues = await ctx.client.findAll(tracker.class.Issue, query as never, { limit: 1000 }) + for (const issue of issues) { + const clear: Record = { [field]: null } + await ctx.client.updateDoc(tracker.class.Issue, issue.space as never, issue._id as never, clear as never) + } +} + +async function issueKindFor (ctx: ToolContext, projectId: string): Promise { + const projectQuery: Record = { _id: projectId } + const project = (await ctx.client.findOne(tracker.class.Project, projectQuery as never)) as unknown as + | { type?: string } + | undefined + const taskTypeQuery: Record = { parent: project?.type, ofClass: tracker.class.Issue } + const taskType = (await ctx.client.findOne(task.class.TaskType, taskTypeQuery as never)) as unknown as + | { _id: string } + | undefined + return taskType === undefined + ? 'The project has no issue task type configured, so nothing was created.' + : { kind: taskType._id } +} + +export const WRITE_PROFILES: WriteProfile[] = [ + { + classId: tracker.class.Component, + label: 'Issue component', + writable: ['label', 'description', 'lead'], + required: ['label'], + create: { + space: 'given', + spaceClass: tracker.class.Project, + defaults: () => ({ description: emptyMarkup(), lead: null, comments: 0, attachments: 0 }) + }, + remove: { + beforeRemove: async (ctx, doc) => { + await detachFromIssues(ctx, 'component', doc) + } + } + }, + { + classId: tracker.class.Milestone, + label: 'Issue milestone', + writable: ['label', 'description', 'status', 'startDate', 'targetDate'], + required: ['label'], + enums: { status: MILESTONE_STATUS_NAMES }, + create: { + space: 'given', + spaceClass: tracker.class.Project, + defaults: () => ({ + description: emptyMarkup(), + status: MilestoneStatus.Planned, + comments: 0, + attachments: 0, + startDate: null, + targetDate: Date.now() + DEFAULT_MILESTONE_SPAN_MS + }) + }, + remove: { + beforeRemove: async (ctx, doc) => { + await detachFromIssues(ctx, 'milestone', doc) + } + } + }, + { + classId: tracker.class.IssueTemplate, + label: 'Issue template', + writable: ['title', 'description', 'priority', 'assignee', 'component', 'milestone', 'estimation', 'labels'], + required: ['title'], + enums: { priority: PRIORITY_NAMES }, + create: { + space: 'given', + spaceClass: tracker.class.Project, + defaults: () => ({ + description: emptyMarkup(), + priority: IssuePriority.NoPriority, + assignee: null, + component: null, + milestone: null, + estimation: 0, + children: [], + labels: [], + relations: [], + comments: 0, + attachments: 0 + }), + // The task type decides the `kind`, as it does for a new issue. + prepare: async (ctx, spaceId) => await issueKindFor(ctx, spaceId) + }, + remove: {} + }, + { + classId: tags.class.TagElement, + label: 'Label', + writable: ['title', 'description', 'color', 'targetClass', 'category'], + required: ['title'], + create: { + space: core.space.Workspace, + defaults: (_ctx, data) => ({ + description: '', + targetClass: tracker.class.Issue, + color: colorFor(String(data.title ?? '')), + category: tags.category.NoCategory + }) + }, + remove: {} + }, + { + classId: document.class.Teamspace, + label: 'Document teamspace', + writable: ['name', 'description', 'private', 'archived', 'members', 'owners', 'autoJoin'], + required: ['name'], + pushable: ['members', 'owners'], + create: { + space: core.space.Space, + // The creator becomes a member and owner, as in the web client. + defaults: (ctx) => ({ + description: '', + private: false, + archived: false, + autoJoin: false, + autoJoinForRoles: [], + members: [ctx.account], + owners: [ctx.account], + type: document.spaceType.DefaultTeamspaceType + }) + }, + remove: {} + }, + { + classId: tracker.class.Project, + label: 'Tracker project', + writable: ['name', 'description', 'private', 'archived', 'members', 'owners', 'autoJoin', 'defaultAssignee'], + pushable: ['members', 'owners'] + }, + { classId: tracker.class.Issue, label: 'Issue', writable: [], remove: { attached: true } }, + { classId: document.class.Document, label: 'Document', writable: [], remove: {} } +] + +export const profileFor = (classId: string): WriteProfile | undefined => + WRITE_PROFILES.find((profile) => profile.classId === classId) + +const classesWhere = (test: (profile: WriteProfile) => boolean): string[] => + WRITE_PROFILES.filter(test).map((profile) => profile.classId) + +export const creatableClasses = (): string[] => classesWhere((profile) => profile.create !== undefined) +export const updatableClasses = (): string[] => classesWhere((profile) => profile.writable.length > 0) +export const removableClasses = (): string[] => classesWhere((profile) => profile.remove !== undefined) + +export type CoerceResult = { ok: true, data: WriteData } | { ok: false, error: string } + +function attributesOf (hierarchy: Hierarchy, classId: string): Map { + try { + return hierarchy.getAllAttributes(classId as Ref>, core.class.Doc) + } catch { + return new Map() + } +} + +const toMillis = (value: unknown): number | null | undefined => { + if (value === null) return null + if (typeof value === 'number') return value + if (typeof value === 'string') { + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? undefined : parsed + } + return undefined +} + +/** + * Checks caller-supplied fields against the profile and the model, and converts + * them to stored form: Markdown to rich text, ISO dates to epoch milliseconds, + * enum names to their numeric value. + */ +export function coerceFields (hierarchy: Hierarchy, profile: WriteProfile, input: WriteData): CoerceResult { + const attributes = attributesOf(hierarchy, profile.classId) + const data: WriteData = {} + + for (const [field, value] of Object.entries(input)) { + if (!profile.writable.includes(field)) { + const allowed = profile.writable.length === 0 ? 'none' : profile.writable.join(', ') + return { ok: false, error: `"${field}" cannot be set on ${profile.classId}. Writable fields: ${allowed}.` } + } + const attribute = attributes.get(field) + if (attribute === undefined) { + return { ok: false, error: `"${field}" is not a field of ${profile.classId} in this workspace.` } + } + + const enumNames = profile.enums?.[field] + if (enumNames !== undefined) { + const index = typeof value === 'string' ? enumNames.indexOf(value) : -1 + if (index < 0) return { ok: false, error: `"${field}" must be one of: ${enumNames.join(', ')}.` } + data[field] = index + continue + } + + const type = describeType(attribute.type as never).type + const fail = (expected: string): CoerceResult => ({ ok: false, error: `"${field}" must be ${expected}.` }) + + switch (type) { + case 'String': + if (typeof value !== 'string') return fail('a string') + data[field] = value + break + case 'Number': + case 'Estimation': + if (typeof value !== 'number') return fail('a number') + data[field] = value + break + case 'Boolean': + if (typeof value !== 'boolean') return fail('true or false') + data[field] = value + break + case 'Date': + case 'Timestamp': { + const millis = toMillis(value) + if (millis === undefined) return fail('an ISO-8601 date, epoch milliseconds, or null') + data[field] = millis + break + } + case 'Ref': + if (value !== null && typeof value !== 'string') return fail('an id string or null') + data[field] = value + break + case 'Array': + if (!Array.isArray(value)) return fail('an array') + data[field] = value + break + case 'Markup': + if (typeof value !== 'string') return fail('Markdown text') + data[field] = toMarkup(value) + break + default: + data[field] = value + } + } + + return { ok: true, data } +} From 02e309511aeb6ab59ffa1e482dfc4f00d869b064 Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:28:31 +0530 Subject: [PATCH 09/32] chore(mcp): update lockfile for write-tool dependencies and record findings 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> --- common/config/rush/pnpm-lock.yaml | 8 ++++++- docs/agentlog.md | 36 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 96bba146ca..e7b502bd36 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -31078,12 +31078,18 @@ importers: '@hcengineering/platform': specifier: workspace:^0.7.20 version: link:../../foundations/core/packages/platform + '@hcengineering/rank': + specifier: workspace:^0.7.18 + version: link:../../foundations/core/packages/rank '@hcengineering/server-core': specifier: workspace:^0.7.19 version: link:../../foundations/server/packages/core '@hcengineering/server-token': specifier: workspace:^0.7.18 version: link:../../foundations/core/packages/token + '@hcengineering/tags': + specifier: workspace:^0.7.0 + version: link:../../plugins/tags '@hcengineering/task': specifier: workspace:^0.7.0 version: link:../../plugins/task @@ -61263,7 +61269,7 @@ snapshots: node-loader@2.0.0(webpack@5.102.1): dependencies: loader-utils: 2.0.4 - webpack: 5.102.1(esbuild@0.25.12)(webpack-cli@5.1.4) + webpack: 5.102.1 node-localstorage@2.2.1: dependencies: diff --git a/docs/agentlog.md b/docs/agentlog.md index 5f4b55167f..17df8ebb1a 100644 --- a/docs/agentlog.md +++ b/docs/agentlog.md @@ -31,3 +31,39 @@ Appended by each agent so work can be resumed across sessions. Read only the tai [2026-09-30] FOR MAINTAINER REVIEW: `huly_create_issue` derives the next issue number as `max(number)+1` because Huly's numbering middleware is not vendored here, so two concurrent creates in one project can collide. Needs a decision (numbering hint, server-side sequence, or a follow-up). [2026-09-30] WARNING: never run `node_modules/.bin/format` or `rushx format` inside a package dir — it once emptied 24 source files in pods/mcp. Use only `node common/scripts/install-run-rush.js fast-format --branch develop` from the repo root, and check `find pods/mcp/src -name '*.ts' -size 0` afterwards. + +[2026-09-30] Broadened tool coverage (18 -> 21 tools): added `huly_list_components`, `huly_create_component`, `huly_update_milestone`; `huly_create_issue` gained `parentIssueId` (sub-issues: attachedTo = parent, `parents` chain nearest first, same shape as CreateIssue.svelte) and `componentId`; `huly_update_issue` gained `componentId`. Every refusal (foreign parent, unknown component) happens BEFORE the project sequence is incremented so a rejected call never burns an issue number. + +[2026-09-30] FIXED two bugs the unit tests could not see: (1) `huly_create_milestone` / `huly_list_milestones` used `name`/`dueDate`/`done`/`project`, but `tracker.class.Milestone` has `label`/`targetDate`/`startDate`/`status` and lives in `space`, so creates wrote a malformed doc and lists always came back empty. (2) The documented "pass null to clear" on `huly_update_issue` was unreachable: the validator rejected `null` for `type: 'string'`. Added `JsonSchema.type` arrays plus `nullableStringProp`, used for the clearable fields. + +[2026-09-30] NOT VERIFIED LIVE: the new/fixed milestone, component and sub-issue paths are covered by unit tests (110 pass) and tsc/eslint are clean, but were not run against the smoke stack, because the running `mcp-smoke` container holds the old image and reaching its credentials was refused. To verify: rebuild the image, restart `mcp-smoke`, then create a component, a milestone, a sub-issue and clear a due date. + +[2026-09-30] VERIFIED LIVE (supersedes the NOT VERIFIED note above): rebuilt `platformcollective/mcp:smoke` and ran 20 end-to-end checks against the smoke stack with a throwaway test account and workspace (`mcp-live`) created on the local account service: 21 tools listed; component create/list; milestone create/list/update; issue with component + milestone; sub-issue with sequential numbering; `null` clears dueDate/component/milestone; foreign parent and unknown component refused; `null` still rejected for a non-nullable field. FOUND AND FIXED by the live run: `huly_get_issue` looked up sub-issues with `{parent: id}` (no such field) so `subtasks` was always empty; sub-issues are attached docs, query is now `{space, attachedTo: id}`. 111 unit tests pass. Note for the next agent: a brand-new workspace answers the first tool call with a transient `Forbidden` while it initialises; retry. + +[2026-09-30] Commit 8eb1a8062 holds the component/sub-issue/milestone work. docs/agentlog.md is deliberately NOT committed (user's instruction). + +[2026-09-30] Direction set by the user: the pod should eventually expose everything a user can do and see in Huly, limited by that user's own access. Plan in three layers, all running as the caller: (1) account-service admin tools, (2) generic read tools over any class (list classes, describe, find, get), (3) generic write tools restricted to a verified allowlist, plus document editing via the collaborator. User decisions: deletes are allowed with NO extra confirm guard (tool still carries the destructive hint); raw generic writes only for a verified allowlist of classes. + +[2026-09-30] Layer 1 DONE and verified live (8 tools, 29 total): huly_get_workspace, huly_list_members, huly_update_workspace_name, huly_update_workspace_guest_settings, huly_set_member_role, huly_remove_member, huly_invite_member, huly_create_invite_link. They use `AccountApi` (src/platform/account-api.ts), a deliberately narrow facade over the account client on `WorkspaceSession.accounts`, called with the caller's workspace token, so the account service applies the role rules. A Forbidden comes back as "Your role ... does not allow you to ". Live run with two users (Owner and plain User, each behind its own pod): a User can read the roster but is refused rename, self-promotion to Owner and removing the owner; the last owner cannot remove themselves. Member removal is `leaveWorkspace(target)`; the service needs Maintainer+, and a Maintainer cannot remove an Owner. + +[2026-09-30] Deliberately NOT exposed from the account client: deleteWorkspace, createApiToken/revokeApiToken (an agent minting or revoking credentials), account merge/delete, password changes. Needs an explicit user decision before adding any of them. + +[2026-09-30] Findings: (a) account `getPersonInfo` is service-only, a user token cannot call it, so member names are resolved from workspace Person docs via `personUuid`. A freshly joined member has NO Person doc yet (still none after connecting over REST), so their name is null until the workspace creates the profile. (b) `sendInvite` fails with InternalServerError when the account service has no mail URL configured (the smoke stack has none); the tool now says so and points at huly_create_invite_link. (c) The smoke stack's original workspace credentials were lost when mcp-smoke was recreated; the current live workspace is `mcp-live` with throwaway accounts whose credentials are in the session scratchpad (live.env, live2.env), not in the repo. + +[2026-09-30] NEXT: layer 2, generic read tools (list classes, describe a class, find, get), then layer 3. 124 unit tests pass; tsc and eslint clean. Not committed. + +[2026-09-30] Commit 38bd071ee holds layer 1 (account admin tools). Layer 2 DONE and verified live (4 tools, 33 total), NOT committed: huly_list_classes, huly_describe_class, huly_find, huly_get_doc in src/tools/model-tools.ts. They use the client's Hierarchy (getDescendants/findDomain/getAllAttributes), so there is no hard-coded class list. Queryable = a class with a domain in its chain; abstract classes are refused with a pointer to huly_list_classes. huly_find caps at 200 rows, returns the total, supports field projection and cuts output to 60k chars with a `truncated` flag. huly_get_doc converts rich-text attributes (TypeCollaborativeDoc blob refs and inline TypeMarkup) to Markdown. + +[2026-09-30] Live run (17 checks, local stack): 426 queryable classes; template classes, space kinds and mixins discoverable; describe/find/get work incl. $like and sorting; space types (11), roles (10) and project types (3) read back. Access scoping confirmed: Owner sees 17 issues, a plain User in no project sees 0 and cannot fetch one by id. KNOWN QUIRK: an unsupported operator such as {$bogus: 1} is silently ignored by the server and returns no rows rather than an error, so the no-match message tells the agent to re-check field names with huly_describe_class. 139 unit tests pass. + +[2026-09-30] NEXT: layer 3, generic writes. Decisions already made by the user: verified allowlist of classes only; deletes allowed with no extra guard. Must reproduce what the web client does per class (see how huly_create_issue needed the sequence $inc, addCollection and the collaborator blob). Also document create/edit through the collaborator. + +[2026-09-30] Commit 0309827cb holds layer 2 (generic reads). Layer 3 DONE and verified live, NOT committed (38 tools total, 166 unit tests): huly_create_doc / huly_update_doc / huly_delete_doc driven by write profiles (src/tools/write-profiles.ts), plus huly_create_document / huly_update_document (src/tools/document-write-tools.ts). New deps in pods/mcp: @hcengineering/rank (page ordering via makeRank, like document-resources) and @hcengineering/tags; rush update, rush check and check-versions.js are clean. MarkupWriter gained `update` (collaborator updateMarkup) so an existing body is replaced in place. + +[2026-09-30] Write profiles, each exercised live: Component, Milestone, IssueTemplate (kind derived from the project task type), tags TagElement (labels, in core:space:Workspace), document Teamspace (creator is member and owner; type DefaultTeamspaceType) for create/update/delete; tracker Project update only (rename, archive, members/owners via $push/$pull); Issue and Document delete only. Deleting a Milestone or Component first nulls that field on every referencing issue (what the web popup does). Deleting a page cascades to its children and deleting a teamspace leaves no pages behind (observed). Deleting an issue also removes its sub-issues (observed). Project DELETE is refused on purpose (archive instead); creating a project, editing project/space TYPES, roles and statuses are NOT supported yet. + +[2026-09-30] IMPORTANT FINDING: the transactor lets any workspace member write to a PUBLIC space, so a plain User (not a member) could rename and delete a teamspace the owner created. The web app blocks this only client-side (plugins/view-resources/src/utils.ts deleteObject: Owner role or createdBy in the caller's SocialIdentity ids). The tools therefore mirror it (src/tools/caller-rights.ts): delete needs workspace Owner or creator; update of a Space-derived class needs workspace Owner, space owner or creator. Private spaces are already invisible to non-members and every write is refused. A plain User CAN create a component in a public project they cannot see; that is the server's rule, left as is. + +[2026-09-30] Limits of the verification: the creator branch of the ownership rule is covered by unit tests only. On the smoke stack the creator's `createdBy` (an email social id) does not match the one SocialIdentity visible in the workspace, and a plain user cannot read back what they create in a project they are not in, so it could not be exercised live. Workspace Owner and space-owner paths are live-verified. The no-matching-filter quirk of huly_find (unsupported operator silently returns nothing) still applies to the generic tools. + +[2026-09-30] NEXT: decide with the user what else is in scope: project create, project types / statuses / roles editing, labels on issues (TagReference via addCollection on the issue), other apps (HR, recruiting, cards, channels), and the remaining account-level items deliberately left out (token minting, workspace delete). From 4b5027512078eec234ff6de241017125c67f827d Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:08:30 +0530 Subject: [PATCH 10/32] fix(mcp): address review blockers on auth, origin and read-only scoping Review of #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> --- ARCHITECTURE_OVERVIEW.md | 3 +- common/config/rush/pnpm-lock.yaml | 6 - docs/agentlog.md | 69 -------- pods/mcp/package.json | 7 +- .../src/__tests__/config-auth-gate.test.ts | 64 ++++++++ pods/mcp/src/__tests__/config.test.ts | 28 ++-- pods/mcp/src/__tests__/test-doubles.ts | 15 +- pods/mcp/src/auth/__tests__/auth.test.ts | 15 +- .../__tests__/authenticator-factory.test.ts | 114 +++++++++++++ .../configured-authenticator.test.ts | 25 ++- pods/mcp/src/auth/authenticator-factory.ts | 45 ++--- pods/mcp/src/auth/authenticator.ts | 15 +- pods/mcp/src/auth/configured-authenticator.ts | 15 +- pods/mcp/src/auth/http-types.ts | 15 +- pods/mcp/src/auth/huly-token-authenticator.ts | 15 +- pods/mcp/src/auth/token-extractor.ts | 15 +- pods/mcp/src/config.ts | 60 +++++-- pods/mcp/src/error.ts | 15 +- pods/mcp/src/index.ts | 15 +- pods/mcp/src/mcp/__tests__/dispatcher.test.ts | 15 +- pods/mcp/src/mcp/__tests__/protocol.test.ts | 15 +- .../mcp/__tests__/readonly-write-gate.test.ts | 143 ++++++++++++++++ .../__tests__/registry-and-session.test.ts | 52 ++++-- pods/mcp/src/mcp/__tests__/validation.test.ts | 15 +- pods/mcp/src/mcp/dispatcher.ts | 32 ++-- pods/mcp/src/mcp/protocol.ts | 15 +- pods/mcp/src/mcp/schema.ts | 15 +- pods/mcp/src/mcp/session.ts | 37 +++-- pods/mcp/src/mcp/streamable-http.ts | 19 +-- pods/mcp/src/mcp/tool.ts | 47 ++++-- pods/mcp/src/mcp/validation.ts | 15 +- .../__tests__/error-handler.test.ts | 15 +- .../middleware/__tests__/origin-guard.test.ts | 154 ++++++++++++++++++ .../middleware/__tests__/rate-limiter.test.ts | 15 +- pods/mcp/src/middleware/index.ts | 56 +++++-- pods/mcp/src/middleware/rate-limiter.ts | 32 ++-- .../mcp/src/platform/__tests__/markup.test.ts | 15 +- .../workspace-client-provider.test.ts | 85 ++++++++++ pods/mcp/src/platform/account-api.ts | 15 +- pods/mcp/src/platform/collaborator-writer.ts | 15 +- pods/mcp/src/platform/markup-reader.ts | 15 +- pods/mcp/src/platform/markup.ts | 15 +- .../src/platform/workspace-client-provider.ts | 54 +++--- pods/mcp/src/server.ts | 39 +++-- .../src/tools/__tests__/account-tools.test.ts | 15 +- .../__tests__/document-write-tools.test.ts | 15 +- .../__tests__/generic-write-tools.test.ts | 15 +- .../src/tools/__tests__/model-tools.test.ts | 15 +- pods/mcp/src/tools/__tests__/tools.test.ts | 15 +- pods/mcp/src/tools/account-tools.ts | 15 +- pods/mcp/src/tools/caller-rights.ts | 15 +- pods/mcp/src/tools/component-tools.ts | 15 +- pods/mcp/src/tools/document-tools.ts | 15 +- pods/mcp/src/tools/document-write-tools.ts | 15 +- pods/mcp/src/tools/generic-write-tools.ts | 15 +- pods/mcp/src/tools/issue-tools.ts | 15 +- pods/mcp/src/tools/model-tools.ts | 15 +- pods/mcp/src/tools/people-tools.ts | 15 +- pods/mcp/src/tools/project-tools.ts | 15 +- pods/mcp/src/tools/register.ts | 15 +- pods/mcp/src/tools/search-tool.ts | 15 +- pods/mcp/src/tools/shared.ts | 15 +- pods/mcp/src/tools/write-profiles.ts | 15 +- 63 files changed, 918 insertions(+), 868 deletions(-) delete mode 100644 docs/agentlog.md create mode 100644 pods/mcp/src/__tests__/config-auth-gate.test.ts create mode 100644 pods/mcp/src/auth/__tests__/authenticator-factory.test.ts create mode 100644 pods/mcp/src/mcp/__tests__/readonly-write-gate.test.ts create mode 100644 pods/mcp/src/middleware/__tests__/origin-guard.test.ts create mode 100644 pods/mcp/src/platform/__tests__/workspace-client-provider.test.ts diff --git a/ARCHITECTURE_OVERVIEW.md b/ARCHITECTURE_OVERVIEW.md index 7f372ed57d..0e21309437 100644 --- a/ARCHITECTURE_OVERVIEW.md +++ b/ARCHITECTURE_OVERVIEW.md @@ -438,9 +438,8 @@ sequenceDiagram ### MCP Configuration (mcp only) - `HULY_TOKEN`: Huly API token for the pod's own identity. Setting it selects self-hosted mode, where MCP clients need no Huly credential. - `HULY_EMAIL` / `HULY_PASSWORD`: Login used when no `HULY_TOKEN` is set -- `HULY_WORKSPACE`: Pin the configured account to a single workspace +- `HULY_WORKSPACE`: Workspace URL slug or ID the configured account is pinned/scoped to - `COLLABORATOR_URL`: Collaborator service URL; enables writing rich-text descriptions -- `HULY_WORKSPACE`: Workspace url slug or id the configured account is scoped to - `MCP_ALLOW_DEFAULT_SECRET`: `false` - Dev stacks only; permits `SECRET=secret` - `MCP_READONLY`: `false` - Refuse every write tool - `MCP_ALLOWED_TOKENS`: Comma-separated token allowlist for multi-tenant mode diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index e7b502bd36..7bfb09967e 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -31111,9 +31111,6 @@ importers: express: specifier: ^4.21.2 version: 4.21.2 - morgan: - specifier: ^1.10.0 - version: 1.10.1 devDependencies: '@hcengineering/platform-rig': specifier: workspace:^0.7.21 @@ -31127,9 +31124,6 @@ importers: '@types/jest': specifier: ^29.5.5 version: 29.5.14 - '@types/morgan': - specifier: ~1.9.9 - version: 1.9.10 '@types/node': specifier: ^24.13.3 version: 24.13.6 diff --git a/docs/agentlog.md b/docs/agentlog.md deleted file mode 100644 index 17df8ebb1a..0000000000 --- a/docs/agentlog.md +++ /dev/null @@ -1,69 +0,0 @@ -# Agent log - -Appended by each agent so work can be resumed across sessions. Read only the tail. - ---- - -[2026-09-30] Added `@hcengineering/pod-mcp` — a Model Context Protocol server over Streamable HTTP, registered in `rush.json`, `common/scripts/docker.sh`, `dev/docker-compose.yaml` and `ARCHITECTURE_OVERVIEW.md`. Branch `feat/mcp-http-server`, based on `origin/develop`. - -[2026-09-30] Decision: built in THIS repo rather than a separate repository. Reasons: (1) the pod consumes `@hcengineering/*` as `workspace:^` deps, so it can never drift from the platform API; out of repo it would pin published versions, and `@hcengineering/document` is `shouldPublish: false` so it is not even in the publish pipeline; (2) the Docker image ships automatically on the next `v*` tag with zero CI changes, because `docker:build`/`docker:push` are blanket rush commands; (3) self-hosters get it by adding one compose service; (4) it inherits the platform's logging, metrics and analytics conventions. - -[2026-09-30] Rejected the community repo `ZubeidHendricks/huly-mcp` as a source to copy. Audited it: 6 commits over 4 days, last touched 2025-05-01. It is NOT an MCP server — it uses a custom JSON-RPC dialect (`huly.findPerson`, `huly.createIssue`) with no `initialize`/`tools/list`/`tools/call`. `src/api/hulyApi.ts` is 100% hardcoded fixtures and `src/index.ts` hardcodes `MOCK_MODE = true`. There is NO LICENSE file (package.json claims MIT but no grant exists), so there is nothing to vendor legally. It has no auth, CORS `*` on write endpoints, and a Dockerfile that cannot build from a clean clone. Its 8-action inventory was used only as a feature checklist. - -[2026-09-30] Auth design: two modes behind one `Authenticator` interface. `configured` (self-host) reads `HULY_TOKEN` or `HULY_EMAIL`+`HULY_PASSWORD` from the pod env so MCP clients need no Huly credential; `perRequest` (multi-tenant) has the client send its own Huly API token. Decorators: optional `MCP_ALLOWED_TOKENS` allowlist, and `MCP_READONLY` clamp applied outermost so it cannot be bypassed by a token flag. - -[2026-09-30] Security decisions worth remembering: registered `setApiTokenRevocationChecker` at boot, because 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. Never uses the system account, so all writes stay permission-checked and attributed to the caller. Added a per-client rate limiter because standalone pods get no limiting from the platform. - -[2026-09-30] No `@modelcontextprotocol/sdk` dependency was added on purpose. The server side of MCP is JSON-RPC 2.0 plus a small envelope, and the repo has zero external AI SDK deps and no zod. `src/mcp/` is transport- and platform-agnostic and unit tested. Swapping in the official SDK later is a change to `src/mcp/` only. - -[2026-09-30] FIXED: first `rush update` failed — `eslint-plugin-promise@^6.21.0` does not exist (latest is 7.3.0). Corrected to `^6.1.1` to match `pods/link-preview`. Also corrected `api-client` to `workspace:^0.7.19` and `analytics-service` to `workspace:^0.7.18` to match the real package versions; the wrong values would have failed `rush check` and `common/scripts/check-versions.js` in CI. - -[2026-09-30] NEXT: verify `rush build --to @hcengineering/pod-mcp` and `rushx test` pass, then open the PR against `develop`. - -[2026-09-30] CORRECTION to the entry above: the dependency ranges that pass `rush check` and `check-versions.js` follow repo convention (sibling pods declare the higher range: `api-client` `^0.7.25`, `analytics-service` `^0.7.19`), not the literal package.json versions. Build, validate, `rush check`, `check-versions.js`, `tsc --noEmit` and jest are all green. - -[2026-09-30] Runtime smoke test WITHOUT a live Huly (pod started via ts-node, `SECRET` set, `ACCOUNTS_URL` unreachable): `/api/v1/health` 200 with 18 tools; missing/garbage/forged bearer → 401; malformed JSON → 400; GET/DELETE without `Mcp-Session-Id` → 400; CORS preflight 204; unknown route 404; boot refuses `SECRET` unset or `secret`. FOUND AND FIXED: an oversized body returned 500 because `errorHandler` did not map body-parser errors; it now returns 413 (regression tests in `src/middleware/__tests__/error-handler.test.ts`). - -[2026-09-30] NOT VERIFIED: no tool handler has run against a real Huly workspace, and the authenticated `initialize` → `tools/list` → `tools/call` path needs a live account service. Treat tool handlers as unproven at runtime until someone runs the smoke test from `pods/mcp/README.md` against a dev stack. - -[2026-09-30] Known non-issue: `rush test --to @hcengineering/pod-mcp` also runs upstream tests, and `@hcengineering/measurements` `src/__tests__/performance.test.ts` has a timing assertion that flakes under parallel load. It passes 70/70 standalone and this branch does not touch it. Do not chase it. - -[2026-09-30] FOR MAINTAINER REVIEW: `huly_create_issue` derives the next issue number as `max(number)+1` because Huly's numbering middleware is not vendored here, so two concurrent creates in one project can collide. Needs a decision (numbering hint, server-side sequence, or a follow-up). - -[2026-09-30] WARNING: never run `node_modules/.bin/format` or `rushx format` inside a package dir — it once emptied 24 source files in pods/mcp. Use only `node common/scripts/install-run-rush.js fast-format --branch develop` from the repo root, and check `find pods/mcp/src -name '*.ts' -size 0` afterwards. - -[2026-09-30] Broadened tool coverage (18 -> 21 tools): added `huly_list_components`, `huly_create_component`, `huly_update_milestone`; `huly_create_issue` gained `parentIssueId` (sub-issues: attachedTo = parent, `parents` chain nearest first, same shape as CreateIssue.svelte) and `componentId`; `huly_update_issue` gained `componentId`. Every refusal (foreign parent, unknown component) happens BEFORE the project sequence is incremented so a rejected call never burns an issue number. - -[2026-09-30] FIXED two bugs the unit tests could not see: (1) `huly_create_milestone` / `huly_list_milestones` used `name`/`dueDate`/`done`/`project`, but `tracker.class.Milestone` has `label`/`targetDate`/`startDate`/`status` and lives in `space`, so creates wrote a malformed doc and lists always came back empty. (2) The documented "pass null to clear" on `huly_update_issue` was unreachable: the validator rejected `null` for `type: 'string'`. Added `JsonSchema.type` arrays plus `nullableStringProp`, used for the clearable fields. - -[2026-09-30] NOT VERIFIED LIVE: the new/fixed milestone, component and sub-issue paths are covered by unit tests (110 pass) and tsc/eslint are clean, but were not run against the smoke stack, because the running `mcp-smoke` container holds the old image and reaching its credentials was refused. To verify: rebuild the image, restart `mcp-smoke`, then create a component, a milestone, a sub-issue and clear a due date. - -[2026-09-30] VERIFIED LIVE (supersedes the NOT VERIFIED note above): rebuilt `platformcollective/mcp:smoke` and ran 20 end-to-end checks against the smoke stack with a throwaway test account and workspace (`mcp-live`) created on the local account service: 21 tools listed; component create/list; milestone create/list/update; issue with component + milestone; sub-issue with sequential numbering; `null` clears dueDate/component/milestone; foreign parent and unknown component refused; `null` still rejected for a non-nullable field. FOUND AND FIXED by the live run: `huly_get_issue` looked up sub-issues with `{parent: id}` (no such field) so `subtasks` was always empty; sub-issues are attached docs, query is now `{space, attachedTo: id}`. 111 unit tests pass. Note for the next agent: a brand-new workspace answers the first tool call with a transient `Forbidden` while it initialises; retry. - -[2026-09-30] Commit 8eb1a8062 holds the component/sub-issue/milestone work. docs/agentlog.md is deliberately NOT committed (user's instruction). - -[2026-09-30] Direction set by the user: the pod should eventually expose everything a user can do and see in Huly, limited by that user's own access. Plan in three layers, all running as the caller: (1) account-service admin tools, (2) generic read tools over any class (list classes, describe, find, get), (3) generic write tools restricted to a verified allowlist, plus document editing via the collaborator. User decisions: deletes are allowed with NO extra confirm guard (tool still carries the destructive hint); raw generic writes only for a verified allowlist of classes. - -[2026-09-30] Layer 1 DONE and verified live (8 tools, 29 total): huly_get_workspace, huly_list_members, huly_update_workspace_name, huly_update_workspace_guest_settings, huly_set_member_role, huly_remove_member, huly_invite_member, huly_create_invite_link. They use `AccountApi` (src/platform/account-api.ts), a deliberately narrow facade over the account client on `WorkspaceSession.accounts`, called with the caller's workspace token, so the account service applies the role rules. A Forbidden comes back as "Your role ... does not allow you to ". Live run with two users (Owner and plain User, each behind its own pod): a User can read the roster but is refused rename, self-promotion to Owner and removing the owner; the last owner cannot remove themselves. Member removal is `leaveWorkspace(target)`; the service needs Maintainer+, and a Maintainer cannot remove an Owner. - -[2026-09-30] Deliberately NOT exposed from the account client: deleteWorkspace, createApiToken/revokeApiToken (an agent minting or revoking credentials), account merge/delete, password changes. Needs an explicit user decision before adding any of them. - -[2026-09-30] Findings: (a) account `getPersonInfo` is service-only, a user token cannot call it, so member names are resolved from workspace Person docs via `personUuid`. A freshly joined member has NO Person doc yet (still none after connecting over REST), so their name is null until the workspace creates the profile. (b) `sendInvite` fails with InternalServerError when the account service has no mail URL configured (the smoke stack has none); the tool now says so and points at huly_create_invite_link. (c) The smoke stack's original workspace credentials were lost when mcp-smoke was recreated; the current live workspace is `mcp-live` with throwaway accounts whose credentials are in the session scratchpad (live.env, live2.env), not in the repo. - -[2026-09-30] NEXT: layer 2, generic read tools (list classes, describe a class, find, get), then layer 3. 124 unit tests pass; tsc and eslint clean. Not committed. - -[2026-09-30] Commit 38bd071ee holds layer 1 (account admin tools). Layer 2 DONE and verified live (4 tools, 33 total), NOT committed: huly_list_classes, huly_describe_class, huly_find, huly_get_doc in src/tools/model-tools.ts. They use the client's Hierarchy (getDescendants/findDomain/getAllAttributes), so there is no hard-coded class list. Queryable = a class with a domain in its chain; abstract classes are refused with a pointer to huly_list_classes. huly_find caps at 200 rows, returns the total, supports field projection and cuts output to 60k chars with a `truncated` flag. huly_get_doc converts rich-text attributes (TypeCollaborativeDoc blob refs and inline TypeMarkup) to Markdown. - -[2026-09-30] Live run (17 checks, local stack): 426 queryable classes; template classes, space kinds and mixins discoverable; describe/find/get work incl. $like and sorting; space types (11), roles (10) and project types (3) read back. Access scoping confirmed: Owner sees 17 issues, a plain User in no project sees 0 and cannot fetch one by id. KNOWN QUIRK: an unsupported operator such as {$bogus: 1} is silently ignored by the server and returns no rows rather than an error, so the no-match message tells the agent to re-check field names with huly_describe_class. 139 unit tests pass. - -[2026-09-30] NEXT: layer 3, generic writes. Decisions already made by the user: verified allowlist of classes only; deletes allowed with no extra guard. Must reproduce what the web client does per class (see how huly_create_issue needed the sequence $inc, addCollection and the collaborator blob). Also document create/edit through the collaborator. - -[2026-09-30] Commit 0309827cb holds layer 2 (generic reads). Layer 3 DONE and verified live, NOT committed (38 tools total, 166 unit tests): huly_create_doc / huly_update_doc / huly_delete_doc driven by write profiles (src/tools/write-profiles.ts), plus huly_create_document / huly_update_document (src/tools/document-write-tools.ts). New deps in pods/mcp: @hcengineering/rank (page ordering via makeRank, like document-resources) and @hcengineering/tags; rush update, rush check and check-versions.js are clean. MarkupWriter gained `update` (collaborator updateMarkup) so an existing body is replaced in place. - -[2026-09-30] Write profiles, each exercised live: Component, Milestone, IssueTemplate (kind derived from the project task type), tags TagElement (labels, in core:space:Workspace), document Teamspace (creator is member and owner; type DefaultTeamspaceType) for create/update/delete; tracker Project update only (rename, archive, members/owners via $push/$pull); Issue and Document delete only. Deleting a Milestone or Component first nulls that field on every referencing issue (what the web popup does). Deleting a page cascades to its children and deleting a teamspace leaves no pages behind (observed). Deleting an issue also removes its sub-issues (observed). Project DELETE is refused on purpose (archive instead); creating a project, editing project/space TYPES, roles and statuses are NOT supported yet. - -[2026-09-30] IMPORTANT FINDING: the transactor lets any workspace member write to a PUBLIC space, so a plain User (not a member) could rename and delete a teamspace the owner created. The web app blocks this only client-side (plugins/view-resources/src/utils.ts deleteObject: Owner role or createdBy in the caller's SocialIdentity ids). The tools therefore mirror it (src/tools/caller-rights.ts): delete needs workspace Owner or creator; update of a Space-derived class needs workspace Owner, space owner or creator. Private spaces are already invisible to non-members and every write is refused. A plain User CAN create a component in a public project they cannot see; that is the server's rule, left as is. - -[2026-09-30] Limits of the verification: the creator branch of the ownership rule is covered by unit tests only. On the smoke stack the creator's `createdBy` (an email social id) does not match the one SocialIdentity visible in the workspace, and a plain user cannot read back what they create in a project they are not in, so it could not be exercised live. Workspace Owner and space-owner paths are live-verified. The no-matching-filter quirk of huly_find (unsupported operator silently returns nothing) still applies to the generic tools. - -[2026-09-30] NEXT: decide with the user what else is in scope: project create, project types / statuses / roles editing, labels on issues (TagReference via addCollection on the issue), other apps (HR, recruiting, cards, channels), and the remaining account-level items deliberately left out (token minting, workspace delete). diff --git a/pods/mcp/package.json b/pods/mcp/package.json index 0d8fdffbe2..81ee1c4f90 100644 --- a/pods/mcp/package.json +++ b/pods/mcp/package.json @@ -2,7 +2,6 @@ "name": "@hcengineering/pod-mcp", "version": "0.7.0", "main": "lib/index.js", - "svelte": "src/index.ts", "types": "types/index.d.ts", "files": [ "lib/**/*", @@ -52,8 +51,7 @@ "ts-node": "^10.9.2", "typescript": "^5.9.3", "@types/cors": "^2.8.12", - "@types/express": "^4.17.13", - "@types/morgan": "~1.9.9" + "@types/express": "^4.17.13" }, "dependencies": { "@hcengineering/account-client": "workspace:^0.7.25", @@ -77,7 +75,6 @@ "@hcengineering/tracker": "workspace:^0.7.0", "cors": "^2.8.5", "dotenv": "^16.4.5", - "express": "^4.21.2", - "morgan": "^1.10.0" + "express": "^4.21.2" } } diff --git a/pods/mcp/src/__tests__/config-auth-gate.test.ts b/pods/mcp/src/__tests__/config-auth-gate.test.ts new file mode 100644 index 0000000000..9a50136f1b --- /dev/null +++ b/pods/mcp/src/__tests__/config-auth-gate.test.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { isLoopbackHost, loadConfig } from '../config' +import { fakeEnv } from './test-doubles' + +/** + * The reachability gate for `configured` mode. + * + * In that mode the pod holds a real Huly credential and the caller supplies + * none, so the endpoint's only protection is who can open a socket to it. A + * listener on a routable interface with no shared secret would hand that + * credential to anyone who found the port. + */ +const configured = (env: Record = {}): NodeJS.ProcessEnv => + fakeEnv({ SECRET: 'not-the-default', HULY_TOKEN: 'static-huly-token', ...env }) + +describe('configured-mode reachability gate', () => { + it('refuses to start on a routable host with no shared secret', () => { + expect(() => loadConfig(configured({ HOST: '0.0.0.0' }))).toThrow(/MCP_ALLOWED_TOKENS/) + }) + + it('names the two ways out of the failure so it is actionable', () => { + expect(() => loadConfig(configured({ HOST: '0.0.0.0' }))).toThrow(/MCP_ALLOWED_TOKENS[\s\S]*127\.0\.0\.1/) + }) + + it('starts when an allowlist supplies the shared secret', () => { + const config = loadConfig(configured({ HOST: '0.0.0.0', MCP_ALLOWED_TOKENS: 'client-secret' })) + expect(config.AllowedTokens).toEqual(['client-secret']) + }) + + it.each(['127.0.0.1', 'localhost', '::1', '[::1]'])('starts on loopback host %s without an allowlist', (host) => { + expect(loadConfig(configured({ HOST: host })).Host).toBe(host) + }) + + it('still requires the secret when an allowlist is present', () => { + expect(() => loadConfig(fakeEnv({ HULY_TOKEN: 'x', MCP_ALLOWED_TOKENS: 'y', HOST: '0.0.0.0' }))).toThrow( + /SECRET must be set/ + ) + }) + + it('does not apply to perRequest mode, where every caller brings its own token', () => { + const config = loadConfig(fakeEnv({ SECRET: 'not-the-default', HOST: '0.0.0.0' })) + expect(config.AuthMode).toBe('perRequest') + expect(config.AllowedTokens).toEqual([]) + }) +}) + +describe('isLoopbackHost', () => { + it.each([ + ['127.0.0.1', true], + ['127.0.0.53', false], + ['localhost', true], + ['LOCALHOST', true], + ['::1', true], + ['[::1]', true], + ['0.0.0.0', false], + ['::', false], + ['192.168.1.10', false], + ['example.internal', false], + ['', false] + ])('classifies %s as loopback=%s', (host, expected) => { + expect(isLoopbackHost(host)).toBe(expected) + }) +}) diff --git a/pods/mcp/src/__tests__/config.test.ts b/pods/mcp/src/__tests__/config.test.ts index 425648536e..09e6b1dc01 100644 --- a/pods/mcp/src/__tests__/config.test.ts +++ b/pods/mcp/src/__tests__/config.test.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { loadConfig } from '../config' import { fakeEnv } from './test-doubles' @@ -36,3 +23,16 @@ describe('loadConfig secret handling', () => { expect(config.AllowDefaultSecret).toBe(false) }) }) + +describe('loadConfig allowed origins', () => { + it('defaults to an empty allowlist, which fails closed for browsers', () => { + expect(loadConfig(fakeEnv({ SECRET: 'a-real-one' })).AllowedOrigins).toEqual([]) + }) + + it('splits a comma separated list and trims whitespace', () => { + const config = loadConfig( + fakeEnv({ SECRET: 'a-real-one', MCP_ALLOWED_ORIGINS: 'http://localhost:5173, https://huly.example.com,' }) + ) + expect(config.AllowedOrigins).toEqual(['http://localhost:5173', 'https://huly.example.com']) + }) +}) diff --git a/pods/mcp/src/__tests__/test-doubles.ts b/pods/mcp/src/__tests__/test-doubles.ts index 6a10a3804d..cc975b5f07 100644 --- a/pods/mcp/src/__tests__/test-doubles.ts +++ b/pods/mcp/src/__tests__/test-doubles.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type AccountUuid, type MeasureContext, type TxOperations, type WorkspaceUuid } from '@hcengineering/core' diff --git a/pods/mcp/src/auth/__tests__/auth.test.ts b/pods/mcp/src/auth/__tests__/auth.test.ts index 146bd2f7cd..267a9c4802 100644 --- a/pods/mcp/src/auth/__tests__/auth.test.ts +++ b/pods/mcp/src/auth/__tests__/auth.test.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { resolveAuthMode } from '../../config' import { fakeEnv } from '../../__tests__/test-doubles' diff --git a/pods/mcp/src/auth/__tests__/authenticator-factory.test.ts b/pods/mcp/src/auth/__tests__/authenticator-factory.test.ts new file mode 100644 index 0000000000..f38cffdefd --- /dev/null +++ b/pods/mcp/src/auth/__tests__/authenticator-factory.test.ts @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type SessionIdentity } from '../authenticator' +import { AuthenticationError } from '../authenticator' +import { AllowlistedAuthenticator, createAuthenticator } from '../authenticator-factory' +import { loadConfig } from '../../config' +import { fakeEnv, fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' + +const config = (env: Record): ReturnType => + loadConfig(fakeEnv({ SECRET: 'not-the-default', ...env })) + +/** Records what reached the inner authenticator, so ordering is observable. */ +const innerSpy = (): { calls: string[], authenticator: { authenticate: (t: string) => Promise } } => { + const calls: string[] = [] + return { + calls, + authenticator: { + authenticate: async (rawToken: string) => { + calls.push(rawToken) + return fakeIdentity() + } + } + } +} + +const reasonOf = async (promise: Promise): Promise => { + try { + await promise + return undefined + } catch (err) { + return err instanceof AuthenticationError ? err.reason : 'not-an-AuthenticationError' + } +} + +/** + * The allowlist is the credential in `configured` mode, and a pre-filter in + * `perRequest` mode. Either way it must run *before* the real authenticator, + * so a rejected token can never reach the account service. + */ +describe('AllowlistedAuthenticator', () => { + const allowed = ['known-client', 'second-client'] + + it('refuses a request with no credential as missing, so the client is challenged', async () => { + const { calls, authenticator } = innerSpy() + const gate = new AllowlistedAuthenticator(authenticator, allowed) + + expect(await reasonOf(gate.authenticate(''))).toBe('missing') + // `missing` must never be reported for a token the gate already knows is + // absent; the inner authenticator must not have been consulted at all. + expect(calls).toEqual([]) + }) + + it('refuses an unknown credential as forbidden, without a login attempt', async () => { + const { calls, authenticator } = innerSpy() + const gate = new AllowlistedAuthenticator(authenticator, allowed) + + expect(await reasonOf(gate.authenticate('not-the-one'))).toBe('forbidden') + expect(calls).toEqual([]) + }) + + it('passes a listed credential through and returns the identity unchanged', async () => { + const { calls, authenticator } = innerSpy() + const gate = new AllowlistedAuthenticator(authenticator, allowed) + + const identity = await gate.authenticate('known-client') + + expect(calls).toEqual(['known-client']) + expect(identity.readOnly).toBe(false) + }) + + it('separates an empty allowlist from a populated one', async () => { + const { calls, authenticator } = innerSpy() + const gate = new AllowlistedAuthenticator(authenticator, []) + + expect(await reasonOf(gate.authenticate('anything'))).toBe('forbidden') + expect(calls).toEqual([]) + }) + + it('treats each entry as an exact string, not a pattern', async () => { + const { authenticator } = innerSpy() + const gate = new AllowlistedAuthenticator(authenticator, ['known-client']) + + expect(await reasonOf(gate.authenticate('known-client-extra'))).toBe('forbidden') + expect(await reasonOf(gate.authenticate('Known-Client'))).toBe('forbidden') + }) +}) + +describe('configured-mode reachability through createAuthenticator', () => { + const env = { HULY_TOKEN: 'static', HULY_WORKSPACE: 'my-space', MCP_ALLOWED_TOKENS: 'known-client', HOST: '0.0.0.0' } + + it('rejects a missing credential before the configured authenticator runs', async () => { + const auth = createAuthenticator(fakeMeasureContext(), config(env)) + expect(await reasonOf(auth.authenticate(''))).toBe('missing') + }) + + it('rejects an unlisted credential before the configured authenticator runs', async () => { + const auth = createAuthenticator(fakeMeasureContext(), config(env)) + expect(await reasonOf(auth.authenticate('nope'))).toBe('forbidden') + }) + + // Pass-through is covered by `AllowlistedAuthenticator` above with a fake + // inner: proving it through `createAuthenticator` would need a real login + // attempt against the account service. +}) + +describe('read-only clamp ordering', () => { + it('is applied outside the allowlist, so a listed token can still be clamped', () => { + const auth = createAuthenticator( + fakeMeasureContext(), + config({ HULY_TOKEN: 'static', MCP_ALLOWED_TOKENS: 'known-client', MCP_READONLY: 'true', HOST: '0.0.0.0' }) + ) + expect(auth).toBeDefined() + }) +}) diff --git a/pods/mcp/src/auth/__tests__/configured-authenticator.test.ts b/pods/mcp/src/auth/__tests__/configured-authenticator.test.ts index 02316ba832..fcd43738aa 100644 --- a/pods/mcp/src/auth/__tests__/configured-authenticator.test.ts +++ b/pods/mcp/src/auth/__tests__/configured-authenticator.test.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type AccountClient } from '@hcengineering/account-client' import { type AccountUuid, type PersonUuid, type WorkspaceUuid } from '@hcengineering/core' @@ -58,8 +45,16 @@ function fakeClientFactory ( } } +/** + * Config for these tests. + * + * `HOST` is loopback on purpose: these cases exercise the login exchange, not + * endpoint reachability, and `configured` mode refuses to start on a routable + * HOST without an allowlist. The reachability gate itself is covered in + * `authenticator-factory.test.ts`. + */ const config = (env: Record): ReturnType => - loadConfig({ SECRET: 'not-the-default', ...env }) + loadConfig({ SECRET: 'not-the-default', HOST: '127.0.0.1', ...env }) describe('ConfiguredAuthenticator', () => { it('exchanges an account-level HULY_TOKEN for a workspace token', async () => { diff --git a/pods/mcp/src/auth/authenticator-factory.ts b/pods/mcp/src/auth/authenticator-factory.ts index 6e68c20aec..40f08ef140 100644 --- a/pods/mcp/src/auth/authenticator-factory.ts +++ b/pods/mcp/src/auth/authenticator-factory.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type MeasureContext } from '@hcengineering/core' @@ -21,13 +8,22 @@ import { ConfiguredAuthenticator } from './configured-authenticator' import { HulyTokenAuthenticator } from './huly-token-authenticator' /** - * Optional allowlist applied on top of per-request authentication. + * Allowlist checked before the real authenticator runs. * - * Huly API tokens carry the full rights of their account and cannot be scoped - * narrower, so a shared multi-tenant endpoint needs its own gate: an operator - * can pin the endpoint to specific tokens without changing Huly itself. + * It does two different jobs, and the two need different failure reasons: + * + * - In `perRequest` mode it narrows a shared endpoint down to the tokens an + * operator pinned to it, because Huly API tokens carry the full rights of + * their account and cannot be scoped any narrower. + * - In `configured` mode it is the *only* thing standing between an anonymous + * caller and the configured Huly account. The configured authenticator + * ignores the credential entirely, so without this gate anyone who could + * reach the port would act as that account. + * + * Checking before delegating also means an unknown token never triggers a + * login attempt against the account service. */ -class AllowlistedAuthenticator implements Authenticator { +export class AllowlistedAuthenticator implements Authenticator { private readonly inner: Authenticator private readonly allowed: Set @@ -37,6 +33,12 @@ class AllowlistedAuthenticator implements Authenticator { } async authenticate (rawToken: string): Promise { + // Distinguishing "no credential" from "wrong credential" matters to the + // client: only `missing` gets a WWW-Authenticate challenge back, which is + // what tells a client that it should present a credential at all. + if (rawToken === '') { + throw new AuthenticationError('missing', 'This MCP endpoint requires a bearer token') + } if (!this.allowed.has(rawToken)) { throw new AuthenticationError('forbidden', 'This token is not permitted to use this MCP endpoint') } @@ -63,6 +65,11 @@ class ReadOnlyAuthenticator implements Authenticator { * * Decorators are applied outermost-last so the read-only clamp always wins: it * is the operator's bluntest control and must not be bypassable by a token flag. + * + * `loadConfig` refuses to start in `configured` mode with an empty allowlist on + * a non-loopback HOST, so by the time this runs the shared-secret gate exists + * whenever it is needed. Logging it here makes that visible at boot rather than + * something an operator has to infer from an absent 401. */ export function createAuthenticator (ctx: MeasureContext, config: Config): Authenticator { let authenticator: Authenticator diff --git a/pods/mcp/src/auth/authenticator.ts b/pods/mcp/src/auth/authenticator.ts index 38ae034f8d..97dd27e4dd 100644 --- a/pods/mcp/src/auth/authenticator.ts +++ b/pods/mcp/src/auth/authenticator.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type AccountUuid, type WorkspaceUuid } from '@hcengineering/core' import { type Token } from '@hcengineering/server-token' diff --git a/pods/mcp/src/auth/configured-authenticator.ts b/pods/mcp/src/auth/configured-authenticator.ts index bd93d72a42..ab5834189f 100644 --- a/pods/mcp/src/auth/configured-authenticator.ts +++ b/pods/mcp/src/auth/configured-authenticator.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { getClient as getAccountClient, diff --git a/pods/mcp/src/auth/http-types.ts b/pods/mcp/src/auth/http-types.ts index a6517e4f97..c83a99fc13 100644 --- a/pods/mcp/src/auth/http-types.ts +++ b/pods/mcp/src/auth/http-types.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type Request } from 'express' import { type Token } from '@hcengineering/server-token' diff --git a/pods/mcp/src/auth/huly-token-authenticator.ts b/pods/mcp/src/auth/huly-token-authenticator.ts index 79b87523d1..897079931e 100644 --- a/pods/mcp/src/auth/huly-token-authenticator.ts +++ b/pods/mcp/src/auth/huly-token-authenticator.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { getClient as getAccountClient, diff --git a/pods/mcp/src/auth/token-extractor.ts b/pods/mcp/src/auth/token-extractor.ts index 70ad78480a..a754f9bcfa 100644 --- a/pods/mcp/src/auth/token-extractor.ts +++ b/pods/mcp/src/auth/token-extractor.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 const BEARER = 'bearer ' diff --git a/pods/mcp/src/config.ts b/pods/mcp/src/config.ts index eda5aed5ce..b83bfd8e8f 100644 --- a/pods/mcp/src/config.ts +++ b/pods/mcp/src/config.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { config as dotenv } from 'dotenv' @@ -53,6 +40,21 @@ export interface Config { ReadOnly: boolean /** Identifiers callers may present when `AuthMode` is `perRequest`. */ AllowedTokens: string[] + /** + * Browser origins allowed to call the endpoint, for CORS and Origin checks. + * + * Empty means "no browser origin is allowed": non-browser clients never send + * `Origin`, so they are unaffected, while a web page is refused outright. + */ + AllowedOrigins: string[] + /** + * How many proxies sit in front of this pod. + * + * Express only trusts `X-Forwarded-For` when this is set. Leaving it unset + * makes every proxied request look like it came from the proxy, which collapses + * all clients into one rate-limit bucket. + */ + TrustProxy: number SessionIdleTtlMs: number ClientCacheTtlMs: number LoginCacheTtlMs: number @@ -84,6 +86,18 @@ const list = (value: string | undefined): string[] => const DEFAULT_ACCOUNTS_URL = 'http://huly.local:3000' +/** + * True when `HOST` addresses only this machine. + * + * Loopback is the one situation where an unauthenticated `configured` endpoint is + * acceptable: nothing outside the host can reach it, so there is no remote caller + * to impersonate the configured account. + */ +export function isLoopbackHost (host: string): boolean { + const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, '') + return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' +} + /** * Resolves the auth mode from the environment. * @@ -120,6 +134,8 @@ function buildConfig (env: NodeJS.ProcessEnv): Config { AllowDefaultSecret: bool(env.MCP_ALLOW_DEFAULT_SECRET, false), ReadOnly: bool(env.MCP_READONLY, false), AllowedTokens: list(env.MCP_ALLOWED_TOKENS), + AllowedOrigins: list(env.MCP_ALLOWED_ORIGINS), + TrustProxy: int(env.TRUST_PROXY, 0), SessionIdleTtlMs: int(env.MCP_SESSION_TTL_MS, 30 * 60_000), ClientCacheTtlMs: int(env.MCP_CLIENT_CACHE_TTL_MS, 10 * 60_000), LoginCacheTtlMs: int(env.MCP_LOGIN_CACHE_TTL_MS, 60_000), @@ -151,6 +167,22 @@ function validate (config: Config): Config { if (config.Port < 1 || config.Port > 65535) { throw Error(`PORT is out of range: ${config.Port}`) } + // `configured` mode authenticates nobody: the caller is whatever account the + // pod was configured with. Reachable from anywhere and open to anyone, that + // is an unauthenticated handle on a real account, so a shared secret is + // required unless the listener is loopback-only. AllowlistedAuthenticator + // checks this token before the configured authenticator runs, so + // MCP_ALLOWED_TOKENS doubles as that secret. + if (config.AuthMode === 'configured' && config.AllowedTokens.length === 0 && !isLoopbackHost(config.Host)) { + throw Error( + 'Auth mode is "configured" with no MCP_ALLOWED_TOKENS and HOST is not loopback. ' + + 'Anyone who can reach this port would act as the configured Huly account. ' + + 'Set MCP_ALLOWED_TOKENS, or bind HOST to 127.0.0.1 for a local-only server.' + ) + } + if (config.TrustProxy < 0) { + throw Error(`TRUST_PROXY must not be negative: ${config.TrustProxy}`) + } return config } diff --git a/pods/mcp/src/error.ts b/pods/mcp/src/error.ts index 771722a170..bb7721f560 100644 --- a/pods/mcp/src/error.ts +++ b/pods/mcp/src/error.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 export class HttpError extends Error { readonly status: number diff --git a/pods/mcp/src/index.ts b/pods/mcp/src/index.ts index 681ba84745..aa7e6ba657 100644 --- a/pods/mcp/src/index.ts +++ b/pods/mcp/src/index.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { Analytics } from '@hcengineering/analytics' import { configureAnalytics, createOpenTelemetryMetricsContext, SplitLogger } from '@hcengineering/analytics-service' diff --git a/pods/mcp/src/mcp/__tests__/dispatcher.test.ts b/pods/mcp/src/mcp/__tests__/dispatcher.test.ts index 679cccfbb3..3cf9e7f1ed 100644 --- a/pods/mcp/src/mcp/__tests__/dispatcher.test.ts +++ b/pods/mcp/src/mcp/__tests__/dispatcher.test.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { fakeIdentity, fakeMeasureContext, fakeWorkspaceSession } from '../../__tests__/test-doubles' import { McpDispatcher } from '../dispatcher' diff --git a/pods/mcp/src/mcp/__tests__/protocol.test.ts b/pods/mcp/src/mcp/__tests__/protocol.test.ts index c42e23aa54..9176629467 100644 --- a/pods/mcp/src/mcp/__tests__/protocol.test.ts +++ b/pods/mcp/src/mcp/__tests__/protocol.test.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { errorResponse, isJsonRpcNotification, isJsonRpcRequest, isJsonRpcId, successResponse } from '../protocol' diff --git a/pods/mcp/src/mcp/__tests__/readonly-write-gate.test.ts b/pods/mcp/src/mcp/__tests__/readonly-write-gate.test.ts new file mode 100644 index 0000000000..90ac9ced9a --- /dev/null +++ b/pods/mcp/src/mcp/__tests__/readonly-write-gate.test.ts @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type TxOperations } from '@hcengineering/core' + +import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' +import { type SessionIdentity } from '../../auth/authenticator' +import { type AccountApi, type AccountApiFactory } from '../../platform/account-api' +import { CachingWorkspaceClientProvider, type ClientFactory } from '../../platform/workspace-client-provider' +import { McpDispatcher } from '../dispatcher' +import { type JsonRpcResponseMessage } from '../protocol' +import { McpSession } from '../session' +import { type HulyTool, ToolRegistry } from '../tool' + +const ctx = fakeMeasureContext() + +const request = (method: string, params?: unknown, id: number = 1): Record => { + const base: Record = { jsonrpc: '2.0', id, method } + return params === undefined ? base : { ...base, params } +} + +const resultOf = (response: JsonRpcResponseMessage | null): { isError?: boolean, content: Array<{ text: string }> } => { + const payload = response as { result: { isError?: boolean, content: Array<{ text: string }> } } + return payload.result +} + +interface Harness { + dispatcher: McpDispatcher + provider: CachingWorkspaceClientProvider + /** One entry per client build, in call order. */ + built: SessionIdentity[] + /** Every write that actually reached a tool handler. */ + writes: string[] +} + +const makeHarness = (): Harness => { + const built: SessionIdentity[] = [] + const writes: string[] = [] + + const createClient: ClientFactory = async (identity) => { + built.push(identity) + const client = { close: async () => {} } + return client as unknown as TxOperations + } + const createAccounts: AccountApiFactory = () => Object.create(null) as AccountApi + + const provider = new CachingWorkspaceClientProvider({ + ctx, + createClient, + createAccounts, + sweepIntervalMs: 600_000 + }) + + const echoTool: HulyTool = { + name: 'echo', + title: 'Echo', + description: 'echoes', + readOnly: true, + inputSchema: { type: 'object', properties: { value: { type: 'string' } } }, + handler: async (_c, args) => ({ content: [{ type: 'text', text: String(args.value) }] }) + } + const writeTool: HulyTool = { + name: 'write', + title: 'Write', + description: 'writes', + readOnly: false, + inputSchema: { type: 'object' }, + handler: async () => { + writes.push('write') + return { content: [{ type: 'text', text: 'written' }] } + } + } + + const dispatcher = new McpDispatcher({ + ctx, + registry: new ToolRegistry().registerAll([echoTool, writeTool]), + serverName: 'huly-mcp', + serverVersion: '0.7.0', + // Mirrors server.ts: the workspace client comes from the shared cache, + // keyed by the identity the transport verified for this request. + resolveSession: async (session) => await provider.get(session.identity) + }) + + return { dispatcher, provider, built, writes } +} + +const initialize = async (dispatcher: McpDispatcher, session: McpSession): Promise => { + await dispatcher.dispatch(session, request('initialize')) +} + +describe('read-only token against a warm full-access cache', () => { + it('refuses the write and uses a separate cache entry instead of the full-access client', async () => { + const { dispatcher, provider, built, writes } = makeHarness() + + // Step 1: the user's full-access token fills the cache first. + const full = new McpSession('sess-full', fakeIdentity({ workspaceToken: 'full-token' }), 0) + await initialize(dispatcher, full) + const allowed = await dispatcher.dispatch(full, request('tools/call', { name: 'write' })) + expect(resultOf(allowed).content[0].text).toBe('written') + expect(writes).toHaveLength(1) + + // Step 2: a read-only token for the SAME account:workspace. Before the + // fix this reused the cached full-access session, so the gate saw + // readOnly === false and the write ran under the full-access token. + const readOnly = new McpSession( + 'sess-ro', + fakeIdentity({ readOnly: true, workspaceToken: 'read-only-token' }), + 0 + ) + await initialize(dispatcher, readOnly) + const refused = await dispatcher.dispatch(readOnly, request('tools/call', { name: 'write' })) + + expect(resultOf(refused).isError).toBe(true) + expect(resultOf(refused).content[0].text).toContain('read-only') + // The handler never ran: no write left the process for the read-only token. + expect(writes).toHaveLength(1) + // Two privilege classes, two cache entries, two clients. + expect(built.map((identity) => identity.readOnly)).toEqual([false, true]) + expect(provider.size).toBe(2) + + await provider.close() + }) + + it('shows each session only the tools its privilege class may run', async () => { + const { dispatcher, provider } = makeHarness() + const readOnly = new McpSession('sess-ro', fakeIdentity({ readOnly: true }), 0) + const full = new McpSession('sess-full', fakeIdentity(), 0) + await initialize(dispatcher, readOnly) + await initialize(dispatcher, full) + + const roList = await dispatcher.dispatch(readOnly, request('tools/list')) + const roTools = (roList as { result: { tools: Array<{ name: string }> } }).result.tools + expect(roTools.map((tool) => tool.name)).toEqual(['echo']) + + const fullList = await dispatcher.dispatch(full, request('tools/list')) + const fullTools = (fullList as { result: { tools: Array<{ name: string }> } }).result.tools + expect(fullTools.map((tool) => tool.name)).toEqual(['echo', 'write']) + + // Listing never resolves a workspace client, so the cache stays cold. + expect(provider.size).toBe(0) + + await provider.close() + }) +}) diff --git a/pods/mcp/src/mcp/__tests__/registry-and-session.test.ts b/pods/mcp/src/mcp/__tests__/registry-and-session.test.ts index 6157d3858a..cdeedecbb7 100644 --- a/pods/mcp/src/mcp/__tests__/registry-and-session.test.ts +++ b/pods/mcp/src/mcp/__tests__/registry-and-session.test.ts @@ -1,23 +1,10 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { fakeIdentity, fakeMeasureContext, fakeToolContext, fakeWorkspaceSession } from '../../__tests__/test-doubles' import { McpDispatcher } from '../dispatcher' import { objectSchema } from '../schema' import { SessionStore } from '../session' -import { type HulyTool, ToolRegistry } from '../tool' +import { toolContext, type HulyTool, ToolRegistry } from '../tool' const ctx = fakeMeasureContext() @@ -140,6 +127,41 @@ describe('SessionStore', () => { expect(session.belongsTo(fakeIdentity({ account: 'other' as never }))).toBe(false) expect(session.belongsTo(fakeIdentity({ workspace: 'other' as never }))).toBe(false) }) + + it('binds a session to one privilege class, not just account and workspace', () => { + const store = new SessionStore({ ctx }) + const readOnlySession = store.create(fakeIdentity({ readOnly: true })) + const fullSession = store.create(fakeIdentity({ readOnly: false })) + + // Same account and workspace, different read-only flag: letting the token + // through would authorize requests as the (more privileged) identity the + // session was initialized with. + expect(readOnlySession.belongsTo(fakeIdentity({ readOnly: true }))).toBe(true) + expect(readOnlySession.belongsTo(fakeIdentity({ readOnly: false }))).toBe(false) + expect(fullSession.belongsTo(fakeIdentity({ readOnly: true }))).toBe(false) + expect(fullSession.belongsTo(fakeIdentity({ readOnly: false }))).toBe(true) + }) +}) + +describe('toolContext', () => { + it('takes readOnly from the identity of this request, not from the cached session', () => { + const cachedFullAccess = fakeWorkspaceSession(fakeIdentity({ workspaceToken: 'full-token' })) + + const guarded = toolContext( + cachedFullAccess, + fakeMeasureContext(), + fakeIdentity({ readOnly: true, workspaceToken: 'read-only-token' }) + ) + expect(guarded.readOnly).toBe(true) + expect(guarded.identity.workspaceToken).toBe('read-only-token') + + // The opposite direction: a cached read-only session must not drag a + // full-access request down either — the passed identity always wins. + const cachedReadOnly = fakeWorkspaceSession(fakeIdentity({ readOnly: true, workspaceToken: 'read-only-token' })) + const allowed = toolContext(cachedReadOnly, fakeMeasureContext(), fakeIdentity({ workspaceToken: 'full-token' })) + expect(allowed.readOnly).toBe(false) + expect(allowed.identity.workspaceToken).toBe('full-token') + }) }) describe('session and dispatcher integration', () => { diff --git a/pods/mcp/src/mcp/__tests__/validation.test.ts b/pods/mcp/src/mcp/__tests__/validation.test.ts index c153121944..fb79fc5efe 100644 --- a/pods/mcp/src/mcp/__tests__/validation.test.ts +++ b/pods/mcp/src/mcp/__tests__/validation.test.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type JsonSchema, nullableStringProp, objectSchema } from '../schema' import { type ValidationResult, validateArguments } from '../validation' diff --git a/pods/mcp/src/mcp/dispatcher.ts b/pods/mcp/src/mcp/dispatcher.ts index 7eabd1d24f..7ef2a34aa5 100644 --- a/pods/mcp/src/mcp/dispatcher.ts +++ b/pods/mcp/src/mcp/dispatcher.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type MeasureContext } from '@hcengineering/core' @@ -42,6 +29,10 @@ export interface McpDispatcherOptions { * Resolves the workspace client for the session. Injected rather than * constructed so the dispatcher stays free of platform dependencies and can be * tested with a fake. + * + * By the time this runs, the transport has matched `session.identity` + * against the current request's token — account, workspace and read-only + * flag — so implementations may trust it as THIS request's identity. */ resolveSession: (session: McpSession) => Promise } @@ -137,6 +128,10 @@ export class McpDispatcher { case 'ping': return {} case 'tools/list': + // The transport checks `session.identity` against THIS request's + // token on every call — including the read-only flag, see + // `McpSession.belongsTo` — so this is the current request's privilege + // class, not a stale one from `initialize`. return { tools: this.registry.list({ readOnly: session.identity.readOnly }) } case 'tools/call': return await this.callTool(session, request.params) @@ -180,7 +175,14 @@ export class McpDispatcher { } const workspaceSession = await this.resolveSession(session) - const context = toolContext(workspaceSession, this.ctx.newChild(args.name, {}, { span: false })) + // Pass the identity authenticated for this request explicitly: the write + // gate must not read `readOnly` from whatever identity sits in the shared + // client cache, because a more privileged request may have filled it first. + const context = toolContext( + workspaceSession, + this.ctx.newChild(args.name, {}, { span: false }), + session.identity + ) return await this.registry.call(args.name, args.arguments, context) } diff --git a/pods/mcp/src/mcp/protocol.ts b/pods/mcp/src/mcp/protocol.ts index 4f0afb77f1..4a8b0212ab 100644 --- a/pods/mcp/src/mcp/protocol.ts +++ b/pods/mcp/src/mcp/protocol.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 // JSON-RPC 2.0 + MCP message shapes. // diff --git a/pods/mcp/src/mcp/schema.ts b/pods/mcp/src/mcp/schema.ts index 950f4ca8ad..329aaf8c4f 100644 --- a/pods/mcp/src/mcp/schema.ts +++ b/pods/mcp/src/mcp/schema.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 // The subset of JSON Schema that MCP tool `inputSchema` values use. // diff --git a/pods/mcp/src/mcp/session.ts b/pods/mcp/src/mcp/session.ts index a7814db30d..e10e07d291 100644 --- a/pods/mcp/src/mcp/session.ts +++ b/pods/mcp/src/mcp/session.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type MeasureContext } from '@hcengineering/core' import { randomUUID as cryptoRandomUUID } from 'node:crypto' @@ -23,7 +10,10 @@ import { type SessionIdentity } from '../auth/authenticator' * * The identity is resolved at `initialize` and never changes afterwards. A * request carrying a different identity than the session's is rejected by the - * transport, so a session id cannot be replayed under another account. + * transport, so a session id cannot be replayed under another account. The + * read-only flag is part of that match too (see `belongsTo`), which is what + * lets `session.identity.readOnly` be trusted as the CURRENT request's + * privilege class on every request the transport lets through. */ export class McpSession { readonly id: string @@ -44,8 +34,23 @@ export class McpSession { this.lastSeen = now } + /** + * Whether this request's identity may keep using this session. + * + * Account and workspace must match (a leaked session id is useless under + * another user), and so must the read-only flag: a session opened with a + * full-access token must never answer a read-only token, or its pinned + * `identity` — which authorization falls back to — would describe a MORE + * privileged token than the one presented right now. The reverse direction + * matters too: a full token must not ride a read-only session either, or + * `tools/list` would hide tools the caller may actually use. + */ belongsTo (identity: SessionIdentity): boolean { - return this.identity.account === identity.account && this.identity.workspace === identity.workspace + return ( + this.identity.account === identity.account && + this.identity.workspace === identity.workspace && + this.identity.readOnly === identity.readOnly + ) } } diff --git a/pods/mcp/src/mcp/streamable-http.ts b/pods/mcp/src/mcp/streamable-http.ts index 1dea9a4561..e61ea74912 100644 --- a/pods/mcp/src/mcp/streamable-http.ts +++ b/pods/mcp/src/mcp/streamable-http.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type MeasureContext } from '@hcengineering/core' import { type Request, type Response } from 'express' @@ -104,7 +91,7 @@ export class StreamableHttpTransport { } if (!session.belongsTo(identity)) { this.ctx.warn('mcp session identity mismatch', { session: session.id }) - res.status(403).json({ error: 'Session belongs to a different account or workspace' }) + res.status(403).json({ error: 'Session was created with different credentials; re-initialize' }) return } } else if (!isInitialize) { @@ -200,7 +187,7 @@ export class StreamableHttpTransport { } if (!session.belongsTo(identity)) { this.ctx.warn('mcp session identity mismatch', { session: session.id }) - res.status(403).json({ error: 'Session belongs to a different account or workspace' }) + res.status(403).json({ error: 'Session was created with different credentials; re-initialize' }) return false } diff --git a/pods/mcp/src/mcp/tool.ts b/pods/mcp/src/mcp/tool.ts index 63a1af3737..03b488e667 100644 --- a/pods/mcp/src/mcp/tool.ts +++ b/pods/mcp/src/mcp/tool.ts @@ -1,20 +1,8 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type AccountUuid, type MeasureContext, type WorkspaceUuid } from '@hcengineering/core' +import { type SessionIdentity } from '../auth/authenticator' import { type WorkspaceSession } from '../platform/workspace-client-provider' import { errorResult, type McpToolCallResult } from './protocol' import { type JsonSchema, type McpToolDescriptor, type ToolArguments } from './schema' @@ -26,6 +14,7 @@ export interface ToolContext extends WorkspaceSession { ctx: MeasureContext account: AccountUuid workspace: WorkspaceUuid + /** Read-only flag of THIS request's identity — the write gate reads it, not the cached one. */ readOnly: boolean } @@ -44,13 +33,35 @@ export interface HulyTool { handler: (ctx: ToolContext, args: ToolArguments) => Promise } -export function toolContext (session: WorkspaceSession, ctx: MeasureContext): ToolContext { +/** + * Builds the context for one tool call. + * + * `identity` must be the identity authenticated for the CURRENT request — it + * is the source of the `readOnly` flag that `ToolRegistry.call` uses to refuse + * writes. It must never be pulled from the shared client cache: a full-access + * request could have populated that cache first, and the write would then run + * under the wrong token. + * + * The parameter defaults to `session.identity` for older call sites. That + * fallback cannot cross privilege classes in practice, because the provider + * keys its cache by the read-only flag too, but production code (the + * dispatcher) passes the identity explicitly so the authorization source is + * visible at the call site. + */ +export function toolContext ( + session: WorkspaceSession, + ctx: MeasureContext, + identity: SessionIdentity = session.identity +): ToolContext { return { ...session, ctx, - account: session.identity.account, - workspace: session.identity.workspace, - readOnly: session.identity.readOnly + // The whole context describes THIS request: identity first, then the + // fields derived from it, so nothing can be read off the shared cache. + identity, + account: identity.account, + workspace: identity.workspace, + readOnly: identity.readOnly } } diff --git a/pods/mcp/src/mcp/validation.ts b/pods/mcp/src/mcp/validation.ts index d9c25a77b4..6aa13a6d5c 100644 --- a/pods/mcp/src/mcp/validation.ts +++ b/pods/mcp/src/mcp/validation.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type JsonSchema, type JsonSchemaType, type ToolArguments } from './schema' diff --git a/pods/mcp/src/middleware/__tests__/error-handler.test.ts b/pods/mcp/src/middleware/__tests__/error-handler.test.ts index 87f9b2b5a0..b81f93c510 100644 --- a/pods/mcp/src/middleware/__tests__/error-handler.test.ts +++ b/pods/mcp/src/middleware/__tests__/error-handler.test.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type NextFunction, type Request, type Response } from 'express' diff --git a/pods/mcp/src/middleware/__tests__/origin-guard.test.ts b/pods/mcp/src/middleware/__tests__/origin-guard.test.ts new file mode 100644 index 0000000000..bb7a89b72e --- /dev/null +++ b/pods/mcp/src/middleware/__tests__/origin-guard.test.ts @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: EPL-2.0 + +import cors from 'cors' +import express, { type Express } from 'express' +import http from 'node:http' + +import { originGuard } from '../index' + +const ALLOWED_ORIGIN = 'http://localhost:5173' +const STRANGER_ORIGIN = 'https://evil.example.com' + +interface Reply { + status: number + headers: http.IncomingHttpHeaders + body: string +} + +interface CallOptions { + method?: string + origin?: string + /** Sends the browser preflight headers (OPTIONS + Access-Control-*). */ + preflight?: boolean +} + +/** + * Rebuilds the middleware order from server.ts: the guard runs before cors, + * so the two only ever interact the way production wires them. + */ +function testApp (allowed: string[]): Express { + const app = express() + app.use(originGuard(allowed)) + app.use( + cors({ + origin: allowed, + maxAge: 86400, + exposedHeaders: ['X-RateLimit-Remaining'], + allowedHeaders: ['Content-Type'] + }) + ) + app.post('/mcp', (_req, res) => { + res.status(200).json({ ok: true }) + }) + return app +} + +/** + * Sends one request against a throwaway listener. + * + * node:http rather than fetch so the test controls every header verbatim: + * fetch implementations may rewrite or refuse headers such as `Origin`, and + * its keep-alive sockets would keep the listener open past the assertion. + */ +function call (app: Express, options: CallOptions = {}): Promise { + return new Promise((resolve, reject) => { + const server = app.listen(0, '127.0.0.1', () => { + const done = (fn: () => void): void => { + server.closeAllConnections() + server.close() + fn() + } + const address = server.address() + if (address === null || typeof address === 'string') { + done(() => { + reject(new Error('test server did not bind a TCP port')) + }) + return + } + + const headers: Record = {} + if (options.origin !== undefined) headers.origin = options.origin + if (options.preflight === true) { + headers['Access-Control-Request-Method'] = 'POST' + headers['Access-Control-Request-Headers'] = 'content-type' + } + + const req = http.request( + { host: '127.0.0.1', port: address.port, path: '/mcp', method: options.method ?? 'POST', headers, agent: false }, + (res) => { + let body = '' + res.setEncoding('utf8') + res.on('data', (chunk) => { + body += chunk + }) + res.on('end', () => { + done(() => { + resolve({ status: res.statusCode ?? 0, headers: res.headers, body }) + }) + }) + } + ) + req.on('error', (err) => { + done(() => { + reject(err) + }) + }) + req.end() + }) + server.on('error', reject) + }) +} + +describe('originGuard', () => { + it('allows requests with no Origin header, so non-browser MCP clients keep working', async () => { + const reply = await call(testApp([ALLOWED_ORIGIN])) + expect(reply.status).toBe(200) + expect(reply.body).toContain('"ok"') + }) + + it('allows an origin from the allowlist and reflects it in the CORS header', async () => { + const reply = await call(testApp([ALLOWED_ORIGIN]), { origin: ALLOWED_ORIGIN }) + expect(reply.status).toBe(200) + expect(reply.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + }) + + it('rejects an origin that is not on the allowlist with 403 and no CORS header', async () => { + const reply = await call(testApp([ALLOWED_ORIGIN]), { origin: STRANGER_ORIGIN }) + expect(reply.status).toBe(403) + expect(JSON.parse(reply.body)).toEqual({ error: 'Origin not allowed' }) + expect(reply.headers['access-control-allow-origin']).toBeUndefined() + }) + + it('fails closed when the allowlist is empty: any Origin is refused', async () => { + const reply = await call(testApp([]), { origin: ALLOWED_ORIGIN }) + expect(reply.status).toBe(403) + expect(JSON.parse(reply.body)).toEqual({ error: 'Origin not allowed' }) + }) + + it('still serves non-browser clients when the allowlist is empty', async () => { + const reply = await call(testApp([])) + expect(reply.status).toBe(200) + }) + + it('answers preflight with 204 for an allowed origin', async () => { + const reply = await call(testApp([ALLOWED_ORIGIN]), { + method: 'OPTIONS', + origin: ALLOWED_ORIGIN, + preflight: true + }) + expect(reply.status).toBe(204) + expect(reply.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + expect(reply.headers['access-control-max-age']).toBe('86400') + }) + + it('refuses preflight from a disallowed origin before cors can answer it', async () => { + const reply = await call(testApp([ALLOWED_ORIGIN]), { + method: 'OPTIONS', + origin: STRANGER_ORIGIN, + preflight: true + }) + expect(reply.status).toBe(403) + expect(JSON.parse(reply.body)).toEqual({ error: 'Origin not allowed' }) + expect(reply.headers['access-control-allow-origin']).toBeUndefined() + }) +}) diff --git a/pods/mcp/src/middleware/__tests__/rate-limiter.test.ts b/pods/mcp/src/middleware/__tests__/rate-limiter.test.ts index 7ad296495c..d02a587d63 100644 --- a/pods/mcp/src/middleware/__tests__/rate-limiter.test.ts +++ b/pods/mcp/src/middleware/__tests__/rate-limiter.test.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { fakeMeasureContext } from '../../__tests__/test-doubles' import { RateLimiter } from '../rate-limiter' diff --git a/pods/mcp/src/middleware/index.ts b/pods/mcp/src/middleware/index.ts index 5c6c47c866..808cb2ae4f 100644 --- a/pods/mcp/src/middleware/index.ts +++ b/pods/mcp/src/middleware/index.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { Analytics } from '@hcengineering/analytics' import { type MeasureContext, metricsAggregate } from '@hcengineering/core' @@ -52,13 +39,46 @@ export const requestLogger = (ctx: MeasureContext): RequestHandler => { } } +/** + * Rejects browser requests whose `Origin` is not on the allowlist. + * + * Why this exists: the MCP spec requires servers to validate `Origin` as a + * defence against DNS rebinding. Without the check, any web page the user + * happens to visit can resolve `http://localhost:4090`, send a request that + * carries the browser's implicit trust of localhost, and drive this server as + * if it were a same-origin local service. The preflight would even pass, + * because a permissive CORS setup answers "yes" to everyone. + * + * Requests without an `Origin` header are allowed through on purpose: + * non-browser clients (Claude Desktop, curl, MCP CLIs) never send it, and the + * header is the only signal a browser-originated call carries. An empty + * allowlist therefore means "no browser origin is accepted" (fail closed) + * rather than "accept everything" — desktop clients keep working either way. + */ +export const originGuard = (allowed: string[]): RequestHandler => { + return (req: Request, res: Response, next: NextFunction) => { + const origin = req.headers.origin + if (origin === undefined || origin === '') { + next() + return + } + // Exact string match only: a browser origin has no trailing slash and no + // wildcard meaning, so anything not explicitly listed is a stranger. + if (typeof origin !== 'string' || !allowed.includes(origin)) { + res.status(403).json({ error: 'Origin not allowed' }) + return + } + next() + } +} + /** * Per-caller rate limiting. * - * The key prefers the client address, which is the only thing available before - * authentication runs. The transactor's own limiter is coupled to a live - * Session, which a stateless MCP endpoint never has, so this is the only limit - * in front of the tools. + * The key comes from `resolveKey`, which reads the client address — the only + * thing available before authentication runs. The transactor's own limiter is + * coupled to a live Session, which a stateless MCP endpoint never has, so this + * is the only limit in front of the tools. */ export const rateLimit = (limiter: RateLimiter, resolveKey: (req: Request) => string): RequestHandler => { return (req: Request, res: Response, next: NextFunction) => { diff --git a/pods/mcp/src/middleware/rate-limiter.ts b/pods/mcp/src/middleware/rate-limiter.ts index 3136531798..5071856d27 100644 --- a/pods/mcp/src/middleware/rate-limiter.ts +++ b/pods/mcp/src/middleware/rate-limiter.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type MeasureContext } from '@hcengineering/core' @@ -20,9 +7,20 @@ import { type MeasureContext } from '@hcengineering/core' * * Standalone pods get no rate limiting from the platform — the transactor's * limiter is coupled to having a live Session — so an MCP endpoint that accepts - * internet traffic needs its own. Buckets are keyed by the authenticated account - * where known and by client address otherwise, so one noisy agent cannot starve - * the rest. + * internet traffic needs its own. + * + * Buckets are keyed by client address, taken from `req.ip`. That is a coarse + * key: it limits per address, not per account or per token, so everyone behind + * one NAT or proxy shares a bucket. The alternative (keying by account) would + * need authentication to run first, and an unauthenticated flood would then cost + * a login attempt per request before anything was rejected. + * + * Two consequences worth knowing when deploying: + * + * - Behind a reverse proxy `req.ip` is the proxy's address unless `TRUST_PROXY` + * is set, which would put the whole deployment in a single bucket. + * - A distributed denial of service from many addresses needs an edge limiter; + * this one only bounds a single noisy client. */ export class RateLimiter { private readonly ctx: MeasureContext diff --git a/pods/mcp/src/platform/__tests__/markup.test.ts b/pods/mcp/src/platform/__tests__/markup.test.ts index 9ff7785b3b..594f01a3a9 100644 --- a/pods/mcp/src/platform/__tests__/markup.test.ts +++ b/pods/mcp/src/platform/__tests__/markup.test.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { fromMarkup, toMarkup } from '../markup' diff --git a/pods/mcp/src/platform/__tests__/workspace-client-provider.test.ts b/pods/mcp/src/platform/__tests__/workspace-client-provider.test.ts new file mode 100644 index 0000000000..83a8ca051d --- /dev/null +++ b/pods/mcp/src/platform/__tests__/workspace-client-provider.test.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type TxOperations } from '@hcengineering/core' + +import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' +import { type SessionIdentity } from '../../auth/authenticator' +import { type AccountApi, type AccountApiFactory } from '../account-api' +import { CachingWorkspaceClientProvider, type ClientFactory } from '../workspace-client-provider' + +/** A client stub that is only good enough to be closed during teardown. */ +const fakeClient = (): TxOperations => { + const client = { close: async () => {} } + return client as unknown as TxOperations +} + +const createAccounts: AccountApiFactory = () => Object.create(null) as AccountApi + +interface ProviderHarness { + provider: CachingWorkspaceClientProvider + /** One entry per client build, in call order — proves how many cache entries were filled. */ + built: SessionIdentity[] +} + +const makeProvider = (): ProviderHarness => { + const built: SessionIdentity[] = [] + const createClient: ClientFactory = async (identity) => { + built.push(identity) + return fakeClient() + } + + const provider = new CachingWorkspaceClientProvider({ + ctx: fakeMeasureContext(), + createClient, + createAccounts, + // Long enough that the sweeper never fires inside a test. + sweepIntervalMs: 600_000 + }) + return { provider, built } +} + +describe('CachingWorkspaceClientProvider cache key', () => { + it('serves a read-only identity from its own entry, not from a full-access one', async () => { + const { provider, built } = makeProvider() + const full = fakeIdentity({ workspaceToken: 'full-token' }) + const readOnly = fakeIdentity({ readOnly: true, workspaceToken: 'read-only-token' }) + + // The full-access identity fills the cache first — the scenario from the + // review: the read-only request must NOT inherit that entry. + await provider.get(full) + const readOnlySession = await provider.get(readOnly) + + expect(readOnlySession.identity.readOnly).toBe(true) + expect(readOnlySession.identity.workspaceToken).toBe('read-only-token') + expect(provider.size).toBe(2) + expect(built.map((identity) => identity.readOnly)).toEqual([false, true]) + + await provider.close() + }) + + it('hands out the requesting identity even when the cache is already warm', async () => { + const { provider, built } = makeProvider() + await provider.get(fakeIdentity({ workspaceToken: 'first-token' })) + const session = await provider.get(fakeIdentity({ workspaceToken: 'second-token' })) + + // Same privilege class means one shared client, but the identity a tool + // sees (and the write gate reads) belongs to THIS request. + expect(built).toHaveLength(1) + expect(session.identity.workspaceToken).toBe('second-token') + expect(provider.size).toBe(1) + + await provider.close() + }) + + it('still collapses concurrent first-hits into a single build', async () => { + const { provider, built } = makeProvider() + const identity = fakeIdentity() + + const [first, second] = await Promise.all([provider.get(identity), provider.get(identity)]) + + expect(built).toHaveLength(1) + expect(first.client).toBe(second.client) + + await provider.close() + }) +}) diff --git a/pods/mcp/src/platform/account-api.ts b/pods/mcp/src/platform/account-api.ts index a35f4a5c77..c26e97d54c 100644 --- a/pods/mcp/src/platform/account-api.ts +++ b/pods/mcp/src/platform/account-api.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { getClient as getAccountClient } from '@hcengineering/account-client' import { diff --git a/pods/mcp/src/platform/collaborator-writer.ts b/pods/mcp/src/platform/collaborator-writer.ts index c51f2cc4f5..4991bb51c1 100644 --- a/pods/mcp/src/platform/collaborator-writer.ts +++ b/pods/mcp/src/platform/collaborator-writer.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { getClient as getCollaboratorClient } from '@hcengineering/collaborator-client' import { makeCollabId, type Class, type Doc, type Ref } from '@hcengineering/core' diff --git a/pods/mcp/src/platform/markup-reader.ts b/pods/mcp/src/platform/markup-reader.ts index aa15a71e59..e914b6ca98 100644 --- a/pods/mcp/src/platform/markup-reader.ts +++ b/pods/mcp/src/platform/markup-reader.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 /** * Resolves a markup blob reference to its text content. diff --git a/pods/mcp/src/platform/markup.ts b/pods/mcp/src/platform/markup.ts index 29d55eb456..2fdc1f7bc2 100644 --- a/pods/mcp/src/platform/markup.ts +++ b/pods/mcp/src/platform/markup.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type Markup } from '@hcengineering/core' import { jsonToMarkup, markupToJSON } from '@hcengineering/text-core' diff --git a/pods/mcp/src/platform/workspace-client-provider.ts b/pods/mcp/src/platform/workspace-client-provider.ts index 2406ed1682..826006ebf3 100644 --- a/pods/mcp/src/platform/workspace-client-provider.ts +++ b/pods/mcp/src/platform/workspace-client-provider.ts @@ -1,20 +1,7 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { createRestTxOperations } from '@hcengineering/api-client' -import { type AccountUuid, type MeasureContext, type TxOperations, type WorkspaceUuid } from '@hcengineering/core' +import { type MeasureContext, type TxOperations } from '@hcengineering/core' import { type SessionIdentity } from '../auth/authenticator' import { type AccountApi, type AccountApiFactory } from './account-api' @@ -61,10 +48,33 @@ interface CacheEntry { const DEFAULT_IDLE_TTL_MS = 10 * 60_000 const DEFAULT_SWEEP_INTERVAL_MS = 60_000 -const keyOf = (account: AccountUuid, workspace: WorkspaceUuid): string => `${account}:${workspace}` +/** + * Cache key for one client: account, workspace, privilege class. + * + * The read-only flag is part of the key on purpose. A `WorkspaceSession` + * carries the identity (and the workspace token) of whoever populated the + * cache, so a read-only token sharing a key with a full-access token would get + * a session whose `readOnly` says `false` and whose token can write — the + * read-only gate would open for the wrong request. `ro` / `rw` keeps the key + * short but readable in log output. + */ +const keyOf = (identity: SessionIdentity): string => + `${identity.account}:${identity.workspace}:${identity.readOnly ? 'ro' : 'rw'}` + +/** + * Re-labels a cached session with the identity of the request being served. + * + * The expensive parts (client, account API, markup reader) stay shared — the + * key already guarantees the two identities have the same account, workspace + * and privilege class. What must NOT be shared is the identity itself: tools + * and the read-only gate read `readOnly` and `workspaceToken` off it, and + * those must describe this request, not whichever request filled the cache. + */ +const withCallerIdentity = (session: WorkspaceSession, identity: SessionIdentity): WorkspaceSession => + identity === session.identity ? session : { ...session, identity } /** - * Caches one `TxOperations` per (account, workspace). + * Caches one `TxOperations` per (account, workspace, read-only flag). * * Building a client is not cheap: `createRestTxOperations` fetches the account * and the full workspace model over HTTP. MCP sessions are long lived and many @@ -74,7 +84,9 @@ const keyOf = (account: AccountUuid, workspace: WorkspaceUuid): string => `${acc * Caching per *account* (not just per workspace) matters for correctness, not * just speed: a client is bound to a social id, and every write is attributed * to that social id. Sharing one client between users would attribute their - * edits to whoever happened to populate the cache first. + * edits to whoever happened to populate the cache first. The same logic + * applies to the read-only flag: two entries, two privilege classes, no + * cross-contamination. */ export class CachingWorkspaceClientProvider implements WorkspaceClientProvider { private readonly ctx: MeasureContext @@ -105,18 +117,18 @@ export class CachingWorkspaceClientProvider implements WorkspaceClientProvider { } async get (identity: SessionIdentity): Promise { - const key = keyOf(identity.account, identity.workspace) + const key = keyOf(identity) const cached = this.cache.get(key) if (cached !== undefined) { cached.lastUsed = this.now() - return cached.session + return withCallerIdentity(cached.session, identity) } // Collapse concurrent first-hits for the same identity into one build, // otherwise a burst of parallel tool calls each loads the whole model. const existing = this.inFlight.get(key) - if (existing !== undefined) return await existing + if (existing !== undefined) return withCallerIdentity(await existing, identity) const creation = (async (): Promise => { const client = await this.createClient(identity) diff --git a/pods/mcp/src/server.ts b/pods/mcp/src/server.ts index 3350eb99e5..df3b11b958 100644 --- a/pods/mcp/src/server.ts +++ b/pods/mcp/src/server.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type MeasureContext } from '@hcengineering/core' import cors from 'cors' @@ -29,6 +16,7 @@ import { keepAlive, KEEP_ALIVE_MAX, KEEP_ALIVE_TIMEOUT, + originGuard, rateLimit, requestLogger, statistics @@ -98,8 +86,25 @@ export function createServer (deps: ServerDependencies): McpServer { const app = express() app.disable('x-powered-by') + // Behind nginx/ingress every connection physically arrives from the proxy's + // address, so req.ip would report the proxy for all callers and the rate + // limiter (which keys on req.ip) would collapse every client into one + // bucket. TRUST_PROXY=0 — the default — trusts no X-Forwarded-For, so a + // direct connection behaves exactly as it did before this line. + app.set('trust proxy', config.TrustProxy) + + // Must sit before cors(): a disallowed origin has to be refused outright, + // not answered with a failing CORS response, so the browser never even sees + // a preflight result for a site we do not trust. + app.use(originGuard(config.AllowedOrigins)) + app.use( cors({ + // Reflect only the configured origins instead of the wildcard `*`. An + // empty list leaves every browser request without an + // Access-Control-Allow-Origin header (cors skips false values), while + // non-browser clients, which send no Origin, are unaffected. + origin: config.AllowedOrigins, maxAge: 86400, // A browser-based MCP client must be able to read the session id, or // every request after initialize would be rejected as session-less. @@ -115,6 +120,12 @@ export function createServer (deps: ServerDependencies): McpServer { // Limiting sits in front of the MCP routes only, so /health keeps working // when a client manages to exhaust the window. + // + // The key is the client address, not the account: authentication has not run + // yet at this point, and resolving an account first would cost a login attempt + // per request before any flood was rejected. That makes the limit per address, + // so callers behind one NAT share a bucket — and, without `trust proxy` set + // above, so would every client behind one reverse proxy. const rateKey = (req: Request): string => req.ip ?? req.socket.remoteAddress ?? 'unknown' app.use(MCP_ENDPOINT, rateLimit(limiter, rateKey)) diff --git a/pods/mcp/src/tools/__tests__/account-tools.test.ts b/pods/mcp/src/tools/__tests__/account-tools.test.ts index b6f5c787a3..f037959bc9 100644 --- a/pods/mcp/src/tools/__tests__/account-tools.test.ts +++ b/pods/mcp/src/tools/__tests__/account-tools.test.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { AccountRole, type AccountUuid, type TxOperations } from '@hcengineering/core' import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' diff --git a/pods/mcp/src/tools/__tests__/document-write-tools.test.ts b/pods/mcp/src/tools/__tests__/document-write-tools.test.ts index c512596e87..54605ae3a4 100644 --- a/pods/mcp/src/tools/__tests__/document-write-tools.test.ts +++ b/pods/mcp/src/tools/__tests__/document-write-tools.test.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { type TxOperations } from '@hcengineering/core' import document from '@hcengineering/document' diff --git a/pods/mcp/src/tools/__tests__/generic-write-tools.test.ts b/pods/mcp/src/tools/__tests__/generic-write-tools.test.ts index bef87c775b..c33b45fbcd 100644 --- a/pods/mcp/src/tools/__tests__/generic-write-tools.test.ts +++ b/pods/mcp/src/tools/__tests__/generic-write-tools.test.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import contact from '@hcengineering/contact' import core, { AccountRole, type AccountUuid, type Hierarchy, type TxOperations } from '@hcengineering/core' diff --git a/pods/mcp/src/tools/__tests__/model-tools.test.ts b/pods/mcp/src/tools/__tests__/model-tools.test.ts index d2ee3318bf..4a3494355c 100644 --- a/pods/mcp/src/tools/__tests__/model-tools.test.ts +++ b/pods/mcp/src/tools/__tests__/model-tools.test.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import core, { type Hierarchy, type TxOperations } from '@hcengineering/core' diff --git a/pods/mcp/src/tools/__tests__/tools.test.ts b/pods/mcp/src/tools/__tests__/tools.test.ts index a9806029a4..208a3d4f4c 100644 --- a/pods/mcp/src/tools/__tests__/tools.test.ts +++ b/pods/mcp/src/tools/__tests__/tools.test.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import core, { type TxOperations } from '@hcengineering/core' import chunter from '@hcengineering/chunter' diff --git a/pods/mcp/src/tools/account-tools.ts b/pods/mcp/src/tools/account-tools.ts index 93456e1432..d9714ea007 100644 --- a/pods/mcp/src/tools/account-tools.ts +++ b/pods/mcp/src/tools/account-tools.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import contact from '@hcengineering/contact' import { AccountRole, type AccountUuid } from '@hcengineering/core' diff --git a/pods/mcp/src/tools/caller-rights.ts b/pods/mcp/src/tools/caller-rights.ts index 25c8b5f04d..c0a541e5dd 100644 --- a/pods/mcp/src/tools/caller-rights.ts +++ b/pods/mcp/src/tools/caller-rights.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import contact from '@hcengineering/contact' import { AccountRole, type Doc } from '@hcengineering/core' diff --git a/pods/mcp/src/tools/component-tools.ts b/pods/mcp/src/tools/component-tools.ts index 103645f557..f7eda20b75 100644 --- a/pods/mcp/src/tools/component-tools.ts +++ b/pods/mcp/src/tools/component-tools.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { generateId } from '@hcengineering/core' import tracker, { type Component } from '@hcengineering/tracker' diff --git a/pods/mcp/src/tools/document-tools.ts b/pods/mcp/src/tools/document-tools.ts index e28396e691..ee1b7a1024 100644 --- a/pods/mcp/src/tools/document-tools.ts +++ b/pods/mcp/src/tools/document-tools.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { SortingOrder } from '@hcengineering/core' import doc from '@hcengineering/document' diff --git a/pods/mcp/src/tools/document-write-tools.ts b/pods/mcp/src/tools/document-write-tools.ts index 242fd4c487..d932ce3183 100644 --- a/pods/mcp/src/tools/document-write-tools.ts +++ b/pods/mcp/src/tools/document-write-tools.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { generateId, SortingOrder } from '@hcengineering/core' import document, { type Document } from '@hcengineering/document' diff --git a/pods/mcp/src/tools/generic-write-tools.ts b/pods/mcp/src/tools/generic-write-tools.ts index 6df4d2a881..266e9a1a10 100644 --- a/pods/mcp/src/tools/generic-write-tools.ts +++ b/pods/mcp/src/tools/generic-write-tools.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import core, { generateId, type Class, type Doc, type Ref } from '@hcengineering/core' diff --git a/pods/mcp/src/tools/issue-tools.ts b/pods/mcp/src/tools/issue-tools.ts index 5246db3938..fdeee97a79 100644 --- a/pods/mcp/src/tools/issue-tools.ts +++ b/pods/mcp/src/tools/issue-tools.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import chunter, { type ChatMessage } from '@hcengineering/chunter' import core, { generateId, SortingOrder } from '@hcengineering/core' diff --git a/pods/mcp/src/tools/model-tools.ts b/pods/mcp/src/tools/model-tools.ts index 6b673bde56..d90504af98 100644 --- a/pods/mcp/src/tools/model-tools.ts +++ b/pods/mcp/src/tools/model-tools.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import core, { type AnyAttribute, type Class, type Doc, type Hierarchy, type Ref, SortingOrder } from '@hcengineering/core' diff --git a/pods/mcp/src/tools/people-tools.ts b/pods/mcp/src/tools/people-tools.ts index 344d3a0264..f78d96e82b 100644 --- a/pods/mcp/src/tools/people-tools.ts +++ b/pods/mcp/src/tools/people-tools.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import contact, { type Person } from '@hcengineering/contact' import core, { generateId, SortingOrder } from '@hcengineering/core' diff --git a/pods/mcp/src/tools/project-tools.ts b/pods/mcp/src/tools/project-tools.ts index 4741b14b8f..0d01401642 100644 --- a/pods/mcp/src/tools/project-tools.ts +++ b/pods/mcp/src/tools/project-tools.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { SortingOrder, type TxOperations } from '@hcengineering/core' import task, { type Project as TaskProject } from '@hcengineering/task' diff --git a/pods/mcp/src/tools/register.ts b/pods/mcp/src/tools/register.ts index 679f374f42..905aa0d299 100644 --- a/pods/mcp/src/tools/register.ts +++ b/pods/mcp/src/tools/register.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import { ToolRegistry } from '../mcp/tool' import { accountTools } from './account-tools' diff --git a/pods/mcp/src/tools/search-tool.ts b/pods/mcp/src/tools/search-tool.ts index 215d313b1f..6caa96738d 100644 --- a/pods/mcp/src/tools/search-tool.ts +++ b/pods/mcp/src/tools/search-tool.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import contact from '@hcengineering/contact' import doc from '@hcengineering/document' diff --git a/pods/mcp/src/tools/shared.ts b/pods/mcp/src/tools/shared.ts index f3b9a38d71..1067480530 100644 --- a/pods/mcp/src/tools/shared.ts +++ b/pods/mcp/src/tools/shared.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import contact from '@hcengineering/contact' import core, { type Class, type Doc, type Ref, type TxOperations } from '@hcengineering/core' diff --git a/pods/mcp/src/tools/write-profiles.ts b/pods/mcp/src/tools/write-profiles.ts index 43b4ac05b4..7ef9684039 100644 --- a/pods/mcp/src/tools/write-profiles.ts +++ b/pods/mcp/src/tools/write-profiles.ts @@ -1,17 +1,4 @@ -/** - Copyright © 2026 Intabia Fusion. - - Licensed under the Eclipse Public License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. You may - obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - - See the License for the specific language governing permissions and - limitations under the License. -*/ +// SPDX-License-Identifier: EPL-2.0 import core, { type AnyAttribute, type Class, type Doc, type Hierarchy, type Ref } from '@hcengineering/core' import document from '@hcengineering/document' From d1a668cb2968a9b896341fc409c0ac0858e986b7 Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:51:56 +0530 Subject: [PATCH 11/32] fix(mcp): address minor review items in issue, write and endpoint paths 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> --- pods/mcp/README.md | 2 +- pods/mcp/src/__tests__/config.test.ts | 12 ++ pods/mcp/src/config.ts | 9 +- .../middleware/__tests__/endpoints.test.ts | 151 ++++++++++++++++++ pods/mcp/src/middleware/index.ts | 15 +- pods/mcp/src/server.ts | 21 ++- .../__tests__/generic-write-tools.test.ts | 141 +++++++++++++++- pods/mcp/src/tools/__tests__/tools.test.ts | 80 ++++++++++ pods/mcp/src/tools/generic-write-tools.ts | 38 ++++- pods/mcp/src/tools/issue-tools.ts | 107 ++++++++++--- pods/mcp/src/tools/write-profiles.ts | 135 ++++++++++++++-- 11 files changed, 651 insertions(+), 60 deletions(-) create mode 100644 pods/mcp/src/middleware/__tests__/endpoints.test.ts diff --git a/pods/mcp/README.md b/pods/mcp/README.md index 0a26a20527..f2ada9165c 100644 --- a/pods/mcp/README.md +++ b/pods/mcp/README.md @@ -91,7 +91,7 @@ sessions are pinned to the account that opened them. | `MCP_RATE_LIMIT` | `300` | Requests per window, per client | | `MCP_RATE_WINDOW_MS` | `60000` | Rate limit window | | `MCP_MAX_BODY_BYTES` | `1048576` | Max JSON-RPC request body | -| `MCP_STATS` | `true` | Serve `/api/v1/statistics` | +| `MCP_STATS` | `false` | Serve `/api/v1/statistics`. Off by default: the endpoint is unauthenticated and reports CPU and memory, so it must be opted into explicitly | ## Connecting a client diff --git a/pods/mcp/src/__tests__/config.test.ts b/pods/mcp/src/__tests__/config.test.ts index 09e6b1dc01..6fc3ddc666 100644 --- a/pods/mcp/src/__tests__/config.test.ts +++ b/pods/mcp/src/__tests__/config.test.ts @@ -36,3 +36,15 @@ describe('loadConfig allowed origins', () => { expect(config.AllowedOrigins).toEqual(['http://localhost:5173', 'https://huly.example.com']) }) }) + +describe('loadConfig statistics endpoint', () => { + it('leaves /api/v1/statistics disabled by default, so nothing is exposed out of the box', () => { + // No MCP_STATS in the environment: a fresh deployment must not serve + // system figures to unauthenticated callers until an operator opts in. + expect(loadConfig(fakeEnv({ SECRET: 'a-real-one' })).EnableStats).toBe(false) + }) + + it('enables /api/v1/statistics only when MCP_STATS opts in', () => { + expect(loadConfig(fakeEnv({ SECRET: 'a-real-one', MCP_STATS: 'true' })).EnableStats).toBe(true) + }) +}) diff --git a/pods/mcp/src/config.ts b/pods/mcp/src/config.ts index b83bfd8e8f..b96594ff70 100644 --- a/pods/mcp/src/config.ts +++ b/pods/mcp/src/config.ts @@ -61,6 +61,13 @@ export interface Config { RequestRateLimit: number RequestRateWindowMs: number MaxBodyBytes: number + /** + * Serve `/api/v1/statistics` (CPU/memory/metrics payload). + * + * Off by default: the endpoint answers without any credential, so shipping + * it enabled would hand system figures to anyone who knows the path. An + * operator who wants to scrape it opts in with MCP_STATS=true. + */ EnableStats: boolean } @@ -142,7 +149,7 @@ function buildConfig (env: NodeJS.ProcessEnv): Config { RequestRateLimit: int(env.MCP_RATE_LIMIT, 300), RequestRateWindowMs: int(env.MCP_RATE_WINDOW_MS, 60_000), MaxBodyBytes: int(env.MCP_MAX_BODY_BYTES, 1024 * 1024), - EnableStats: bool(env.MCP_STATS, true) + EnableStats: bool(env.MCP_STATS, false) } } diff --git a/pods/mcp/src/middleware/__tests__/endpoints.test.ts b/pods/mcp/src/middleware/__tests__/endpoints.test.ts new file mode 100644 index 0000000000..f73e981132 --- /dev/null +++ b/pods/mcp/src/middleware/__tests__/endpoints.test.ts @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type MeasureContext } from '@hcengineering/core' +import { type Express } from 'express' +import http from 'node:http' + +import { fakeEnv } from '../../__tests__/test-doubles' +import { type Authenticator } from '../../auth/authenticator' +import { loadConfig } from '../../config' +import { ToolRegistry } from '../../mcp/tool' +import { type WorkspaceClientProvider } from '../../platform/workspace-client-provider' +import { createServer } from '../../server' +import { RateLimiter } from '../rate-limiter' + +interface Reply { + status: number + body: string +} + +/** + * A MeasureContext stub for the HTTP layer. + * + * `newChild` backs the request logger, and `metrics` backs the statistics + * handler — both are read while the server is assembled, so a stub without + * them would crash before the first request rather than fail an assertion. + */ +function fakeCtx (): MeasureContext { + const ctx: Record = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + newChild: () => ctx, + metrics: { measurements: {}, value: 0 } + } + return ctx as unknown as MeasureContext +} + +/** + * Builds the real app from `createServer`, the same wiring production uses. + * + * Only the pieces the tested routes touch are real; the client provider and + * authenticator are empty stubs because no request here reaches the MCP + * transport where they would be called. + */ +function testApp (env: Record = {}): Express { + const config = loadConfig(fakeEnv({ SECRET: 'a-real-one', ...env })) + const ctx = fakeCtx() + const { app } = createServer({ + ctx, + config, + registry: new ToolRegistry(), + clients: Object.create(null) as WorkspaceClientProvider, + authenticator: Object.create(null) as Authenticator, + limiter: new RateLimiter(ctx, config.RequestRateLimit, config.RequestRateWindowMs) + }) + return app +} + +/** + * Sends one GET against a throwaway listener, with no credentials of any kind. + * + * node:http rather than fetch, matching origin-guard.test.ts: the test controls + * every header verbatim, and closing the listener in the callback keeps jest + * from hanging on a kept-alive socket. + */ +function call (app: Express, path: string): Promise { + return new Promise((resolve, reject) => { + const server = app.listen(0, '127.0.0.1', () => { + const done = (fn: () => void): void => { + server.closeAllConnections() + server.close() + fn() + } + const address = server.address() + if (address === null || typeof address === 'string') { + done(() => { + reject(new Error('test server did not bind a TCP port')) + }) + return + } + + const req = http.request( + { host: '127.0.0.1', port: address.port, path, method: 'GET', agent: false }, + (res) => { + let body = '' + res.setEncoding('utf8') + res.on('data', (chunk) => { + body += chunk + }) + res.on('end', () => { + done(() => { + resolve({ status: res.statusCode ?? 0, body }) + }) + }) + } + ) + req.on('error', (err) => { + done(() => { + reject(err) + }) + }) + req.end() + }) + server.on('error', reject) + }) +} + +describe('unauthenticated endpoints', () => { + it('answers /api/v1/health with 200 and no credential, so container probes keep working', async () => { + const reply = await call(testApp(), '/api/v1/health') + expect(reply.status).toBe(200) + expect(JSON.parse(reply.body).status).toBe('ok') + }) + + it('exposes only probe-safe fields on /api/v1/health — no authMode, readOnly or sessions', async () => { + const reply = await call(testApp(), '/api/v1/health') + const body: unknown = JSON.parse(reply.body) + // The exact key set matters more than individual omissions: any future + // field added here has to be a conscious decision, not an oversight. + expect(Object.keys(body as Record).sort()).toEqual(['status', 'tools', 'version']) + expect(body).not.toHaveProperty('authMode') + }) + + it('answers 404 identical to an unknown path while MCP_STATS is unset (the default)', async () => { + const reply = await call(testApp(), '/api/v1/statistics') + expect(reply.status).toBe(404) + // Same body the catch-all produces, so probing reveals nothing about + // whether the route exists. + expect(JSON.parse(reply.body)).toEqual({ error: 'Not Found' }) + }) + + it('serves statistics once MCP_STATS=true opts in', async () => { + const reply = await call(testApp({ MCP_STATS: 'true' }), '/api/v1/statistics') + expect(reply.status).toBe(200) + const body = JSON.parse(reply.body) + expect(body.statistics).toHaveProperty('cpu') + expect(body.statistics).toHaveProperty('memory') + expect(body).toHaveProperty('metrics') + }) + + it('keeps the auth mode off the / landing page while it still points at the MCP endpoint', async () => { + // fakeEnv carries no HULY_TOKEN, so the mode in play is `perRequest`; the + // page must not name it (nor the `Auth mode:` line at all). + const reply = await call(testApp(), '/') + expect(reply.status).toBe(200) + expect(reply.body).not.toMatch(/auth mode/i) + expect(reply.body).not.toContain('perRequest') + expect(reply.body).toContain('POST /mcp') + }) +}) diff --git a/pods/mcp/src/middleware/index.ts b/pods/mcp/src/middleware/index.ts index 808cb2ae4f..3c873c629a 100644 --- a/pods/mcp/src/middleware/index.ts +++ b/pods/mcp/src/middleware/index.ts @@ -95,10 +95,21 @@ export const rateLimit = (limiter: RateLimiter, resolveKey: (req: Request) => st } } +/** + * Unauthenticated metrics endpoint — only mounted while `MCP_STATS=true`. + * + * It answers with process metrics plus CPU/memory figures, which is useful for + * a scraper but is still information disclosure to anyone who can reach the + * port, so it must be opted into rather than on by default. When disabled the + * handler passes the request along instead of answering itself: the catch-all + * then replies with the exact 404 every unknown path gets, so probing the URL + * reveals nothing about whether the route exists (a bespoke 403/404 body would + * confirm it does). + */ export const statistics = (ctx: MeasureContext, config: Config): RequestHandler => { - return (req: Request, res: Response) => { + return (req: Request, res: Response, next: NextFunction) => { if (!config.EnableStats) { - res.status(404).json({ message: 'Not Found' }) + next() return } res.setHeader('Content-Type', 'application/json') diff --git a/pods/mcp/src/server.ts b/pods/mcp/src/server.ts index df3b11b958..6fb0f966aa 100644 --- a/pods/mcp/src/server.ts +++ b/pods/mcp/src/server.ts @@ -133,19 +133,31 @@ export function createServer (deps: ServerDependencies): McpServer { app.get(MCP_ENDPOINT, asyncHandler(transport.handleGet)) app.delete(MCP_ENDPOINT, asyncHandler(transport.handleDelete)) + // Liveness probe: must answer 200 with no credential, because that is all a + // Docker/k8s healthcheck can present. What it reports is deliberately narrow: + // `authMode` and `readOnly` described how the server authenticates and + // authorizes — a roadmap for an unauthenticated caller — and `sessions` was a + // live activity counter. `version` and `tools` are fixed at boot and only + // tell an operator that the new image and the tool registry are the ones + // actually serving. app.get('/api/v1/health', (_req: Request, res: Response) => { res.status(200).json({ status: 'ok', version: VERSION, - authMode: config.AuthMode, - readOnly: config.ReadOnly, - tools: registry.size, - sessions: sessions.size + tools: registry.size }) }) + // Mounted unconditionally: while MCP_STATS is unset the handler defers to the + // catch-all below, which answers exactly like any unknown path (see + // `statistics` in middleware/index.ts). app.get('/api/v1/statistics', statistics(ctx, config)) + // A friendly landing page for a human who opens the pod in a browser. Same + // disclosure rule as /api/v1/health: it lists where the MCP endpoint and the + // health probe are, but not the auth mode or the read-only flag — those + // describe the server's security posture and are nobody's business but the + // operator's (who reads them from the environment that set them). app.get('/', (_req: Request, res: Response) => { res .type('text/plain') @@ -155,7 +167,6 @@ export function createServer (deps: ServerDependencies): McpServer { '', `MCP endpoint: POST ${MCP_ENDPOINT}`, 'Health: GET /api/v1/health', - `Auth mode: ${config.AuthMode}${config.ReadOnly ? ' (read-only)' : ''}`, `Tools: ${registry.size}`, '' ].join('\n') diff --git a/pods/mcp/src/tools/__tests__/generic-write-tools.test.ts b/pods/mcp/src/tools/__tests__/generic-write-tools.test.ts index c33b45fbcd..106a895183 100644 --- a/pods/mcp/src/tools/__tests__/generic-write-tools.test.ts +++ b/pods/mcp/src/tools/__tests__/generic-write-tools.test.ts @@ -27,6 +27,11 @@ const arr = { _class: 'core:class:ArrOf', of: str } const num = { _class: 'core:class:TypeNumber' } const attrs = (entries: Record): Map => new Map(Object.entries(entries)) +// `members` and `owners` are ArrOf(TypeAccountUuid()) in the model, so push and +// pull must accept one account id and nothing else. +const accountId = { _class: 'core:class:TypeAccountUuid' } +const idArr = { _class: 'core:class:ArrOf', of: accountId } + const ATTRIBUTES: Record> = { [tracker.class.Component]: attrs({ label: { type: str }, description: { type: markup }, lead: { type: ref } }), [tracker.class.Milestone]: attrs({ @@ -52,11 +57,17 @@ const ATTRIBUTES: Record> = { description: { type: str }, private: { type: bool }, archived: { type: bool }, - members: { type: arr }, - owners: { type: arr }, + members: { type: idArr }, + owners: { type: idArr }, autoJoin: { type: bool } }), - [tracker.class.Project]: attrs({ name: { type: str }, description: { type: str }, archived: { type: bool }, members: { type: arr }, owners: { type: arr } }) + [tracker.class.Project]: attrs({ + name: { type: str }, + description: { type: str }, + archived: { type: bool }, + members: { type: idArr }, + owners: { type: idArr } + }) } const DERIVED: Record = { @@ -77,9 +88,14 @@ interface Calls { removeCollection: unknown[][] } +type FindAll = (cls: string, query: Row, options?: { limit?: number }) => Row[] + interface Script { findOne?: Record - findAll?: Record + /** Per class, or a function for fakes that must react to the query. */ + findAll?: Record | FindAll + /** Lets a fake apply a recorded write, as the workspace would. */ + onOps?: (id: string, ops: Row) => void role?: AccountRole } @@ -88,12 +104,18 @@ function setup (script: Script = {}): { ctx: ToolContext, calls: Calls } { const client = { getHierarchy: hierarchy, findOne: async (cls: string) => script.findOne?.[cls], - findAll: async (cls: string) => script.findAll?.[cls] ?? [], + findAll: async (cls: string, query: Row, options?: { limit?: number }) => { + if (typeof script.findAll === 'function') { + return script.findAll(cls, query, options) + } + return script.findAll?.[cls] ?? [] + }, createDoc: async (cls: string, space: string, values: Row, id: string) => { calls.createDoc.push({ cls, space, values, id }) }, updateDoc: async (cls: string, space: string, id: string, ops: Row) => { calls.updateDoc.push({ cls, space, id, ops }) + script.onOps?.(id, ops) }, removeDoc: async (cls: string, space: string, id: string) => { calls.removeDoc.push([cls, space, id]) @@ -117,6 +139,35 @@ function setup (script: Script = {}): { ctx: ToolContext, calls: Calls } { const textOf = (result: { content: unknown[] }): string => (result.content[0] as { text: string }).text const projectSpace = { _id: PROJECT, _class: tracker.class.Project } +/** + * A query-aware fake for issues linked to one document: findAll only returns + * issues that still carry the reference, and the recorded updates clear it, so + * a sweep has to come back batch after batch until nothing matches. That is the + * contract detachFromIssues relies on to know when it is finished. + */ +function linkedIssues ( + ids: string[], + field: 'milestone' | 'component', + target: string +): { findAll: FindAll, onOps: (id: string, ops: Row) => void, queries: Row[], limits: number[] } { + const issues: Row[] = ids.map((id) => ({ _id: id, space: PROJECT, [field]: target })) + const queries: Row[] = [] + const limits: number[] = [] + return { + queries, + limits, + findAll: (_cls, query, options) => { + queries.push(query) + limits.push(options?.limit ?? 0) + return issues.filter((issue) => issue[field] === query[field]).slice(0, options?.limit) + }, + onOps: (id, ops) => { + const issue = issues.find((row) => row._id === id) + if (issue !== undefined) Object.assign(issue, ops) + } + } +} + describe('huly_create_doc', () => { it('refuses a class without a create profile and lists what is supported', async () => { const { ctx, calls } = setup() @@ -210,6 +261,29 @@ describe('huly_create_doc', () => { expect(calls.createDoc[1]).toMatchObject({ cls: document.class.Teamspace, space: core.space.Space }) expect(calls.createDoc[1].values).toMatchObject({ name: 'Handbook', members: [ME], owners: [ME], private: false }) }) + + it('keeps the creator in members and owners when the caller passes empty lists', async () => { + const { ctx, calls } = setup() + + await createDocTool.handler(ctx, { + classId: document.class.Teamspace, + data: { name: 'Handbook', members: [], owners: [] } + }) + + expect(calls.createDoc).toHaveLength(1) + expect(calls.createDoc[0].values).toMatchObject({ members: [ME], owners: [ME] }) + }) + + it('adds the creator to caller-supplied members and owners instead of replacing them', async () => { + const { ctx, calls } = setup() + + await createDocTool.handler(ctx, { + classId: document.class.Teamspace, + data: { name: 'Handbook', members: ['a2', 'a3'], owners: ['a2'] } + }) + + expect(calls.createDoc[0].values).toMatchObject({ members: [ME, 'a2', 'a3'], owners: [ME, 'a2'] }) + }) }) describe('huly_update_doc', () => { @@ -245,6 +319,25 @@ describe('huly_update_doc', () => { expect(calls.updateDoc).toHaveLength(1) }) + it('refuses a push or pull item that is not the field element type, and writes nothing', async () => { + const space = { _id: 's1', space: core.space.Space, owners: [ME] } + const { ctx, calls } = setup({ findOne: { [document.class.Teamspace]: space } }) + + const numberItem = await updateDocTool.handler(ctx, { classId: document.class.Teamspace, id: 's1', push: { members: 123 } }) + const objectItem = await updateDocTool.handler(ctx, { classId: document.class.Teamspace, id: 's1', push: { owners: { nested: true } } }) + const wholeList = await updateDocTool.handler(ctx, { classId: document.class.Teamspace, id: 's1', pull: { members: ['a2'] } }) + + expect(textOf(numberItem)).toContain('"members" expects a single account id') + expect(textOf(objectItem)).toContain('"owners" expects a single account id') + expect(textOf(wholeList)).toContain('push and pull take ONE item at a time') + expect(calls.updateDoc).toHaveLength(0) + + // Only the value was refused: a correctly typed item still goes through. + await updateDocTool.handler(ctx, { classId: document.class.Teamspace, id: 's1', push: { members: 'a4' } }) + expect(calls.updateDoc).toHaveLength(1) + expect(calls.updateDoc[0].ops).toEqual({ $push: { members: 'a4' } }) + }) + it('lets only a workspace owner, a space owner or the creator manage a space', async () => { const someoneElses = { _id: 's1', space: core.space.Space, owners: ['other'], createdBy: 'not-me' } const call = { classId: document.class.Teamspace, id: 's1', data: { name: 'New name' } } @@ -317,10 +410,12 @@ describe('huly_delete_doc', () => { }) it('detaches issues from a milestone before deleting it', async () => { + const sweep = linkedIssues(['i1', 'i2'], 'milestone', 'm1') const { ctx, calls } = setup({ role: AccountRole.Owner, findOne: { [tracker.class.Milestone]: { _id: 'm1', space: PROJECT } }, - findAll: { [tracker.class.Issue]: [{ _id: 'i1', space: PROJECT }, { _id: 'i2', space: PROJECT }] } + findAll: sweep.findAll, + onOps: sweep.onOps }) await deleteDocTool.handler(ctx, { classId: tracker.class.Milestone, id: 'm1' }) @@ -332,6 +427,40 @@ describe('huly_delete_doc', () => { expect(calls.removeDoc).toEqual([[tracker.class.Milestone, PROJECT, 'm1']]) }) + it('detaches every issue in batches, however many point at the milestone', async () => { + const ids = Array.from({ length: 1500 }, (_unused, index) => `i${index}`) + const sweep = linkedIssues(ids, 'milestone', 'm1') + const { ctx, calls } = setup({ + role: AccountRole.Owner, + findOne: { [tracker.class.Milestone]: { _id: 'm1', space: PROJECT } }, + findAll: sweep.findAll, + onOps: sweep.onOps + }) + + await deleteDocTool.handler(ctx, { classId: tracker.class.Milestone, id: 'm1' }) + + // The same predicate is re-run until it comes back empty: 1000, then 500, then none. + expect(sweep.queries).toEqual([{ milestone: 'm1' }, { milestone: 'm1' }, { milestone: 'm1' }]) + expect(sweep.limits).toEqual([1000, 1000, 1000]) + expect(calls.updateDoc).toHaveLength(1500) + expect(calls.removeDoc).toEqual([[tracker.class.Milestone, PROJECT, 'm1']]) + }) + + it('refuses the delete instead of leaving issues pointing at a milestone it cannot detach', async () => { + const { ctx, calls } = setup({ + role: AccountRole.Owner, + findOne: { [tracker.class.Milestone]: { _id: 'm1', space: PROJECT } }, + // The same issue comes back on every round, as if the clearing never took effect. + findAll: { [tracker.class.Issue]: [{ _id: 'i1', space: PROJECT }] } + }) + + const result = await deleteDocTool.handler(ctx, { classId: tracker.class.Milestone, id: 'm1' }) + + expect(textOf(result)).toContain('did not finish') + expect(textOf(result)).toContain('nothing was deleted') + expect(calls.removeDoc).toHaveLength(0) + }) + it('removes an issue through its parent collection', async () => { const issue = { _id: 'i1', space: PROJECT, attachedTo: 'parent', attachedToClass: tracker.class.Issue, collection: 'subIssues' } const { ctx, calls } = setup({ role: AccountRole.Owner, findOne: { [tracker.class.Issue]: issue } }) diff --git a/pods/mcp/src/tools/__tests__/tools.test.ts b/pods/mcp/src/tools/__tests__/tools.test.ts index 208a3d4f4c..9220420ad5 100644 --- a/pods/mcp/src/tools/__tests__/tools.test.ts +++ b/pods/mcp/src/tools/__tests__/tools.test.ts @@ -3,6 +3,7 @@ import core, { type TxOperations } from '@hcengineering/core' import chunter from '@hcengineering/chunter' import contact from '@hcengineering/contact' +import { makeRank } from '@hcengineering/rank' import task from '@hcengineering/task' import tracker from '@hcengineering/tracker' @@ -37,6 +38,8 @@ interface Script { findAll?: Record Row[])> findOne?: Record sequence?: number + /** Makes the increment return no number, as a workspace that withholds the next issue number would. */ + failIncrement?: boolean } function scriptedClient (script: Script): { client: TxOperations, recorded: Recorded } { @@ -53,6 +56,7 @@ function scriptedClient (script: Script): { client: TxOperations, recorded: Reco }, updateDoc: async (...args: unknown[]) => { recorded.updateDoc.push({ args }) + if (script.failIncrement === true) return {} return { object: { sequence: script.sequence ?? 1 } } }, addCollection: async (cls: string, space: string, attachedTo: string, _attachedToClass: string, collection: string, attributes: Row) => { @@ -160,6 +164,82 @@ describe('huly_create_issue', () => { await createIssueTool.handler(context(client), { projectId: 'missing', title: 'Hello' }) expect(recorded.addCollection).toHaveLength(0) }) + + it('gives the created issue a real rank produced by makeRank', async () => { + const { client, recorded } = scriptedClient({ + ...script(), + findOne: { ...script().findOne, [tracker.class.Issue]: { rank: '0|hzzzzz:' } } + }) + + await createIssueTool.handler(context(client), { projectId: 'proj-1', title: 'Hello' }) + + const rank = recorded.addCollection[0].attributes.rank + expect(rank).toBe(makeRank('0|hzzzzz:', undefined)) + expect(rank).not.toBe('') + expect(rank).not.toBeUndefined() + }) + + it('produces a valid rank when the project has no issues yet', async () => { + const { client, recorded } = scriptedClient(script()) + + await createIssueTool.handler(context(client), { projectId: 'proj-1', title: 'Hello' }) + + const rank = recorded.addCollection[0].attributes.rank + expect(rank).toBe(makeRank(undefined, undefined)) + expect(typeof rank).toBe('string') + expect(rank).not.toBe('') + }) + + it('refuses a milestone from outside the project without incrementing the counter', async () => { + const { client, recorded } = scriptedClient(script()) + + const result = await createIssueTool.handler(context(client), { + projectId: 'proj-1', + title: 'Hello', + milestone: 'ms-other' + }) + + expect(result.structuredContent).toEqual({ created: false }) + expect(JSON.stringify(result.content)).toContain('huly_list_milestones') + expect(recorded.updateDoc).toHaveLength(0) + expect(recorded.addCollection).toHaveLength(0) + }) + + it('refuses an assignee who is not a person in the workspace without incrementing the counter', async () => { + const { client, recorded } = scriptedClient(script()) + + const result = await createIssueTool.handler(context(client), { + projectId: 'proj-1', + title: 'Hello', + assignee: 'ghost' + }) + + expect(result.structuredContent).toEqual({ created: false }) + expect(JSON.stringify(result.content)).toContain('huly_find_people') + expect(recorded.updateDoc).toHaveLength(0) + expect(recorded.addCollection).toHaveLength(0) + }) + + it('writes no description blob when reserving the issue number fails', async () => { + const { client, recorded } = scriptedClient({ ...script(), failIncrement: true }) + const written: string[][] = [] + const writer: MarkupWriter = { + write: async (cls, id, attribute, markdown) => { + written.push([cls, attribute, markdown]) + return `blob-for-${id}` + }, + update: async () => {} + } + + await expect( + createIssueTool.handler(context(client, writer), { projectId: 'proj-1', title: 'Hello', description: 'Body' }) + ).rejects.toThrow('The workspace did not return the next issue number') + + // The reservation ran before the blob write, so nothing was persisted to leak. + expect(recorded.updateDoc).toHaveLength(1) + expect(written).toHaveLength(0) + expect(recorded.addCollection).toHaveLength(0) + }) }) describe('huly_add_issue_comment', () => { diff --git a/pods/mcp/src/tools/generic-write-tools.ts b/pods/mcp/src/tools/generic-write-tools.ts index 266e9a1a10..eabda017d1 100644 --- a/pods/mcp/src/tools/generic-write-tools.ts +++ b/pods/mcp/src/tools/generic-write-tools.ts @@ -1,12 +1,13 @@ // SPDX-License-Identifier: EPL-2.0 -import core, { generateId, type Class, type Doc, type Ref } from '@hcengineering/core' +import core, { generateId, type Class, type Doc, type Hierarchy, type Ref } from '@hcengineering/core' import { textResult } from '../mcp/protocol' import { objectSchema, stringProp } from '../mcp/schema' import { type HulyTool } from '../mcp/tool' import { mayDelete, mayManageSpace } from './caller-rights' import { + checkArrayItem, coerceFields, creatableClasses, profileFor, @@ -30,6 +31,9 @@ const unsupported = (action: string, classId: string, supported: string[]): stri const isObject = (value: unknown): value is WriteData => typeof value === 'object' && value !== null && !Array.isArray(value) +/** Narrows a field value to the id list that array fields such as members hold. */ +const idList = (value: unknown): string[] | undefined => (Array.isArray(value) ? (value as string[]) : undefined) + export const createDocTool: HulyTool = { name: 'huly_create_doc', title: 'Create a document of a supported class', @@ -93,7 +97,19 @@ export const createDocTool: HulyTool = { spaceId = create.space } - let values: WriteData = { ...create.defaults(ctx, coerced.data), ...coerced.data } + // The caller's data wins over the defaults, except for the array fields the + // profile lists in unionDefaults: there the default items are kept as well, + // so a caller passing `members: []` cannot create a teamspace they are not + // a member or owner of — and therefore cannot manage afterwards. + const defaults = create.defaults(ctx, coerced.data) + let values: WriteData = { ...defaults, ...coerced.data } + for (const field of create.unionDefaults ?? []) { + const base = idList(defaults[field]) + const given = idList(coerced.data[field]) + if (base !== undefined && given !== undefined) { + values[field] = [...new Set([...base, ...given])] + } + } if (create.prepare !== undefined) { const prepared = await create.prepare(ctx, spaceId, values) if (typeof prepared === 'string') return textResult(prepared, { created: false }) @@ -107,8 +123,13 @@ export const createDocTool: HulyTool = { } } -/** Builds the `$push` / `$pull` operations, refusing fields the profile does not allow. */ +/** + * Builds the `$push` / `$pull` operations, refusing fields the profile does not + * allow and items whose type does not match what the field holds — `$push` and + * `$pull` each carry ONE item, never a whole replacement list. + */ function arrayOperations ( + hierarchy: Hierarchy, profile: WriteProfile, push: WriteData | undefined, pull: WriteData | undefined @@ -116,12 +137,14 @@ function arrayOperations ( const operations: WriteData = {} for (const [key, source] of [['$push', push], ['$pull', pull]] as const) { if (source === undefined) continue - for (const field of Object.keys(source)) { + for (const [field, item] of Object.entries(source)) { if (!(profile.pushable ?? []).includes(field)) { const pushable = profile.pushable ?? [] const allowed = pushable.length === 0 ? 'none' : pushable.join(', ') return { ok: false, error: `"${field}" cannot be added to or removed from on ${profile.classId}. Allowed: ${allowed}.` } } + const wrongType = checkArrayItem(hierarchy, profile, field, item) + if (wrongType !== undefined) return { ok: false, error: wrongType } } operations[key] = source } @@ -162,6 +185,7 @@ export const updateDocTool: HulyTool = { if (!coerced.ok) return textResult(coerced.error, { updated: false }) const arrays = arrayOperations( + hierarchy, profile, isObject(args.push) ? args.push : undefined, isObject(args.pull) ? args.pull : undefined @@ -240,7 +264,11 @@ export const deleteDocTool: HulyTool = { ) } - await remove.beforeRemove?.(ctx, doc) + // A string from beforeRemove refuses the delete, as a string from prepare refuses a create. + const refusal = await remove.beforeRemove?.(ctx, doc) + if (typeof refusal === 'string') { + return textResult(refusal, { deleted: false }) + } if (remove.attached === true) { await ctx.client.removeCollection( diff --git a/pods/mcp/src/tools/issue-tools.ts b/pods/mcp/src/tools/issue-tools.ts index fdeee97a79..fd83e1289d 100644 --- a/pods/mcp/src/tools/issue-tools.ts +++ b/pods/mcp/src/tools/issue-tools.ts @@ -1,7 +1,8 @@ // SPDX-License-Identifier: EPL-2.0 import chunter, { type ChatMessage } from '@hcengineering/chunter' -import core, { generateId, SortingOrder } from '@hcengineering/core' +import core, { type Class, type Doc, generateId, type Ref, SortingOrder } from '@hcengineering/core' +import { makeRank } from '@hcengineering/rank' import task from '@hcengineering/task' import tracker, { IssuePriority, MilestoneStatus, type Issue, type Milestone } from '@hcengineering/tracker' @@ -294,7 +295,7 @@ export const createIssueTool: HulyTool = { assignee: stringProp('Person id to assign. Use huly_find_people to look one up.'), dueDate: stringProp('ISO-8601 due date, e.g. 2026-12-31 or 2026-12-31T17:00:00Z.'), startDate: stringProp('ISO-8601 start date.'), - milestone: stringProp('Milestone id to attach the issue to.'), + milestone: stringProp('Milestone id to attach the issue to. Must belong to the same project.'), componentId: stringProp('Component id from huly_list_components. Must belong to the same project.'), parentIssueId: stringProp('Create this as a sub-issue of the issue with this id. Must be in the same project.') }, @@ -344,33 +345,73 @@ export const createIssueTool: HulyTool = { } } - if (args.componentId !== undefined && !(await componentInProject(ctx, args.componentId as string, projectId))) { + const componentId = args.componentId as string | undefined + if (componentId !== undefined && !(await docInProject(ctx, tracker.class.Component, componentId, projectId))) { return textResult( - `No component with id ${String(args.componentId)} exists in project ${projectId}, so the issue was not ` + + `No component with id ${componentId} exists in project ${projectId}, so the issue was not ` + 'created. Call huly_list_components for the valid ids.', { created: false } ) } + // Milestones live in the project's space just like components do, so the + // same "does it belong here" check applies. + const milestoneId = args.milestone as string | undefined + if (milestoneId !== undefined && !(await docInProject(ctx, tracker.class.Milestone, milestoneId, projectId))) { + return textResult( + `No milestone with id ${milestoneId} exists in project ${projectId}, so the issue was not ` + + 'created. Call huly_list_milestones for the valid ids.', + { created: false } + ) + } + + // The assignee must be a person this workspace can see. personNames is the + // same lookup the list tools use to turn assignee ids into names, so an id + // it does not know would otherwise persist and render as "Unknown" forever. + const assignee = args.assignee as string | undefined + if (assignee !== undefined && !(await personNames(ctx.client, [assignee])).has(assignee)) { + return textResult( + `No person with id ${assignee} is visible in this workspace, so the issue was not created. ` + + 'Call huly_find_people for the valid ids.', + { created: false } + ) + } + // The body is stored as a blob owned by the collaborator service. Refuse up - // front, before a number is consumed, when that service is not configured. + // front, before a number is consumed, when that service is not configured: + // a missing COLLABORATOR_URL is fixable by the caller, a burned number is not. const issueId = generateId() const description = ((args.description as string | undefined) ?? '').trim() - let descriptionRef: string | null = null - if (description !== '') { - if (ctx.markupWriter === undefined) { - return textResult( - 'A description was given but this server has no COLLABORATOR_URL configured, so the issue was not ' + - 'created. Retry without a description, or ask the administrator to set COLLABORATOR_URL.', - { created: false } - ) - } - descriptionRef = await ctx.markupWriter.write(tracker.class.Issue, issueId, 'description', description) + if (description !== '' && ctx.markupWriter === undefined) { + return textResult( + 'A description was given but this server has no COLLABORATOR_URL configured, so the issue was not ' + + 'created. Retry without a description, or ask the administrator to set COLLABORATOR_URL.', + { created: false } + ) } + // A new issue belongs at the END of the project's rank order, so the rank + // is derived from the current maximum. This mirrors what the importer and + // the server's RankMiddleware do for issues: with no issues yet the query + // returns nothing and makeRank falls back to the very first rank, so no + // rank string is ever hardcoded here. + const rankQuery: Record = { space: projectId } + const last = await ctx.client.findOne(tracker.class.Issue, rankQuery as never, { + sort: { rank: SortingOrder.Descending }, + projection: { rank: 1 } + }) + const rank = makeRank(last?.rank, undefined) + // Numbers are assigned by incrementing the project's sequence counter, which // the transactor applies atomically. This is what the web client does, so // concurrent creates never receive the same number. + // + // The reservation deliberately runs before the description blob is written. + // If the reservation fails, nothing has been persisted yet. Had the blob + // been written first, a failed reservation would leave it orphaned with no + // issue ever able to reference it again, and no way to reclaim it. A later + // failure costs at most a gap in the identifier sequence, which is purely + // cosmetic. const increment: Record = { $inc: { sequence: 1 } } const incremented = (await ctx.client.updateDoc( tracker.class.Project, @@ -385,18 +426,23 @@ export const createIssueTool: HulyTool = { } const identifier = `${project.identifier ?? '?'}-${number}` + const descriptionRef = + description === '' || ctx.markupWriter === undefined + ? null + : await ctx.markupWriter.write(tracker.class.Issue, issueId, 'description', description) + const attributes: Record = { title: args.title, description: descriptionRef, - assignee: (args.assignee as string | undefined) ?? null, - component: (args.componentId as string | undefined) ?? null, - milestone: (args.milestone as string | undefined) ?? null, + assignee: assignee ?? null, + component: componentId ?? null, + milestone: milestoneId ?? null, number, identifier, kind: taskType._id, status: statusId, priority: priorityIndex(args.priority as string | undefined), - rank: '', + rank, comments: 0, subIssues: 0, // Ancestors nearest first, as the web client stores them. @@ -447,10 +493,20 @@ interface ParentRow { parents?: Array<{ parentId: string, parentTitle: string, space: string, identifier: string }> } -/** True when the component exists and belongs to the given project. */ -async function componentInProject (ctx: ToolContext, componentId: string, projectId: string): Promise { - const query: Record = { _id: componentId, space: projectId } - return (await ctx.client.findOne(tracker.class.Component, query as never)) !== undefined +/** + * True when the document exists and lives in the given project's space. + * + * Components and milestones are both stored with `space` set to the project + * they belong to, so one check covers either kind of id. + */ +async function docInProject ( + ctx: ToolContext, + _class: Ref>, + docId: string, + projectId: string +): Promise { + const query: Record = { _id: docId, space: projectId } + return (await ctx.client.findOne(_class, query as never)) !== undefined } interface ProjectDefaults { @@ -514,9 +570,10 @@ export const updateIssueTool: HulyTool = { if (args.startDate !== undefined) operations.startDate = toTimestamp(args.startDate as string) if (args.milestone !== undefined) operations.milestone = args.milestone if (args.componentId !== undefined) { - if (args.componentId !== null && !(await componentInProject(ctx, args.componentId as string, issue.space))) { + const componentId = args.componentId as string | null + if (componentId !== null && !(await docInProject(ctx, tracker.class.Component, componentId, issue.space))) { return textResult( - `No component with id ${String(args.componentId)} exists in the issue's project, so nothing was changed. ` + + `No component with id ${componentId} exists in the issue's project, so nothing was changed. ` + 'Call huly_list_components for the valid ids.', { updated: false } ) diff --git a/pods/mcp/src/tools/write-profiles.ts b/pods/mcp/src/tools/write-profiles.ts index 7ef9684039..19506a9bc6 100644 --- a/pods/mcp/src/tools/write-profiles.ts +++ b/pods/mcp/src/tools/write-profiles.ts @@ -40,14 +40,24 @@ export interface WriteProfile { /** Class the named space must belong to. */ spaceClass?: string defaults: (ctx: ToolContext, data: WriteData) => WriteData + /** + * Array fields whose default items must survive caller input: the caller's + * list is unioned with the default instead of replacing it. Without this a + * caller could pass `members: []` and create a teamspace they cannot manage. + */ + unionDefaults?: string[] /** Derives fields that need a lookup. Returns a message to refuse the create. */ prepare?: (ctx: ToolContext, spaceId: string, data: WriteData) => Promise } remove?: { /** Attached documents are removed through their parent's collection. */ attached?: boolean - /** Clears references that would otherwise dangle. */ - beforeRemove?: (ctx: ToolContext, doc: Doc) => Promise + /** + * Clears references that would otherwise dangle. Resolves to a message that + * refuses the delete, or undefined to let it proceed — the same convention + * `prepare` uses to refuse a create. + */ + beforeRemove?: (ctx: ToolContext, doc: Doc) => Promise } } @@ -63,13 +73,49 @@ const colorFor = (title: string): number => { const emptyMarkup = (): string => toMarkup('') -/** Sets a reference field to null on every issue that points at the removed document. */ -async function detachFromIssues (ctx: ToolContext, field: 'milestone' | 'component', doc: Doc): Promise { +/** + * Issues detached per round trip while clearing a reference. + * + * Every round re-runs the SAME `{ [field]: doc._id }` query: an issue whose + * field was cleared no longer matches, so the result set only ever shrinks and + * the sweep ends on an empty query instead of on a fixed cut-off. + */ +const DETACH_BATCH_SIZE = 1000 + +/** + * Rounds the sweep may run before the delete is refused. + * + * Clearing an issue removes it from the next round's query, so a healthy + * workspace finishes in `linked issues / batch size` rounds. The cap exists for + * the pathological case where the clears do not take effect: there it turns an + * endless loop into a refused delete, and refusing is the right outcome because + * the alternative is a milestone or component removed while issues still point + * at it — exactly the dangling reference the sweep is here to prevent. + */ +const DETACH_MAX_ROUNDS = 50 + +/** + * Clears the reference on every issue linked to a document that is about to be + * deleted. Returns undefined only after a query that found nothing left, so a + * partial sweep cannot happen unnoticed; when the sweep cannot finish it + * returns a message that refuses the delete and says what is still linked. + */ +async function detachFromIssues (ctx: ToolContext, field: 'milestone' | 'component', doc: Doc): Promise { const query: Record = { [field]: doc._id } - const issues = await ctx.client.findAll(tracker.class.Issue, query as never, { limit: 1000 }) - for (const issue of issues) { - const clear: Record = { [field]: null } - await ctx.client.updateDoc(tracker.class.Issue, issue.space as never, issue._id as never, clear as never) + for (let round = 0; ; round++) { + const issues = await ctx.client.findAll(tracker.class.Issue, query as never, { limit: DETACH_BATCH_SIZE }) + if (issues.length === 0) return undefined + if (round >= DETACH_MAX_ROUNDS) { + return ( + `Detaching issues from this ${field} did not finish: the query still matched ${issues.length} of them ` + + `after ${DETACH_MAX_ROUNDS} batches, so nothing was deleted. ` + + 'No issue will be left referencing a deleted document. Try again later.' + ) + } + for (const issue of issues) { + const clear: Record = { [field]: null } + await ctx.client.updateDoc(tracker.class.Issue, issue.space as never, issue._id as never, clear as never) + } } } @@ -99,9 +145,7 @@ export const WRITE_PROFILES: WriteProfile[] = [ defaults: () => ({ description: emptyMarkup(), lead: null, comments: 0, attachments: 0 }) }, remove: { - beforeRemove: async (ctx, doc) => { - await detachFromIssues(ctx, 'component', doc) - } + beforeRemove: async (ctx, doc) => await detachFromIssues(ctx, 'component', doc) } }, { @@ -123,9 +167,7 @@ export const WRITE_PROFILES: WriteProfile[] = [ }) }, remove: { - beforeRemove: async (ctx, doc) => { - await detachFromIssues(ctx, 'milestone', doc) - } + beforeRemove: async (ctx, doc) => await detachFromIssues(ctx, 'milestone', doc) } }, { @@ -179,7 +221,9 @@ export const WRITE_PROFILES: WriteProfile[] = [ pushable: ['members', 'owners'], create: { space: core.space.Space, - // The creator becomes a member and owner, as in the web client. + // The creator becomes a member and owner, as in the web client, and stays + // one even when the caller passes their own members or owners lists. + unionDefaults: ['members', 'owners'], defaults: (ctx) => ({ description: '', private: false, @@ -303,3 +347,64 @@ export function coerceFields (hierarchy: Hierarchy, profile: WriteProfile, input return { ok: true, data } } + +/** + * What one array element may be, keyed by the type name `describeType` already + * produces for `coerceFields`: the same lookup for both checks, so they can + * never disagree about what a field holds. + */ +interface ElementCheck { + /** How the element reads in an error message. */ + label: string + ok: (value: unknown) => boolean +} + +const ELEMENT_CHECKS: Record = { + String: { label: 'string', ok: (value) => typeof value === 'string' }, + Markup: { label: 'string', ok: (value) => typeof value === 'string' }, + Ref: { label: 'id string', ok: (value) => typeof value === 'string' }, + // AccountUuid and PersonId are the ids members and owners hold. + AccountUuid: { label: 'account id', ok: (value) => typeof value === 'string' }, + PersonId: { label: 'account id', ok: (value) => typeof value === 'string' }, + Number: { label: 'number', ok: (value) => typeof value === 'number' }, + Estimation: { label: 'number', ok: (value) => typeof value === 'number' }, + Boolean: { label: 'boolean', ok: (value) => typeof value === 'boolean' }, + Date: { label: 'date', ok: (value) => toMillis(value) !== undefined }, + Timestamp: { label: 'date', ok: (value) => toMillis(value) !== undefined }, + Array: { label: 'array', ok: (value) => Array.isArray(value) } +} + +/** + * Checks one pushed or pulled item against the element type the model declares + * for the field, so a whole array or a stray object can never be written where + * a single id belongs. Returns undefined when the value is acceptable. + * + * This is a type check only — it does not verify that an id exists, which is + * the transactor's job and which no helper in these tools performs today. + */ +export function checkArrayItem ( + hierarchy: Hierarchy, + profile: WriteProfile, + field: string, + value: unknown +): string | undefined { + const attribute = attributesOf(hierarchy, profile.classId).get(field) + if (attribute === undefined) { + return `"${field}" is not a field of ${profile.classId} in this workspace.` + } + const described = describeType(attribute.type as never) + if (described.type !== 'Array') { + return `"${field}" is not an array field on ${profile.classId}, so nothing can be pushed to or pulled from it.` + } + // `of` is a nested Type normally, but describeType passes a bare id through as a string. + const element = typeof described.of === 'string' ? { type: 'Ref' } : described.of + // A field with no declared element type, or one of a custom type, gives this + // check nothing to judge; the profiles only push ids, which are covered above. + const check = element === undefined ? undefined : ELEMENT_CHECKS[element.type] + if (check === undefined || check.ok(value)) return undefined + const shown = JSON.stringify(value) ?? 'undefined' + return ( + `"${field}" expects a single ${check.label} — push and pull take ONE item at a time, ` + + `e.g. push {"${field}": "<${check.label}>"}. Received: ${shown}.` + ) +} From a9a91eea6eacadad775b5b60fd902194389373ad Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:24:19 +0530 Subject: [PATCH 12/32] feat(tracker): add ProjectField types and validation helpers Co-Authored-By: Claude Sonnet 5.5 --- docs/tracker-projects-parity/plan.md | 538 ++++++++++++++++++ .../src/__tests__/project-field.test.ts | 98 ++++ plugins/tracker/src/index.ts | 6 + plugins/tracker/src/projectField.ts | 206 +++++++ 4 files changed, 848 insertions(+) create mode 100644 docs/tracker-projects-parity/plan.md create mode 100644 plugins/tracker/src/__tests__/project-field.test.ts create mode 100644 plugins/tracker/src/projectField.ts diff --git a/docs/tracker-projects-parity/plan.md b/docs/tracker-projects-parity/plan.md new file mode 100644 index 0000000000..ec336efa97 --- /dev/null +++ b/docs/tracker-projects-parity/plan.md @@ -0,0 +1,538 @@ +# Plan: Bring `/tracker/` to GitHub Projects feature parity + +Branch: `feat/tracker-projects-parity` +Status: **PLAN ONLY** — no implementation code written yet. +Research basis: official GitHub docs + live GraphQL schema + REST API + `gh` CLI v2.98.0. + +--- + +## 0. Corrections to the naive reading of the screenshot + +The reference screenshot is a **Huly-shaped wishlist**, not a literal GitHub default. Verified +against official sources: + +| Common assumption | Reality (verified) | +|---|---| +| GitHub has list / calendar / gantt-timeline / workload layouts | **No — exactly 3 layouts**: `Table`, `Board`, `Roadmap`. Confirmed by the `ProjectV2ViewLayout` enum and the REST `layout` param. Insights is a **separate top-level section**, not a layout. | +| `Current iteration`, `Next iteration`, `Prioritized backlog`, `In review`, `My items` are GitHub default views | **UNCONFIRMED — GitHub ships none of these.** A blank project gets one view of the layout you picked. Those names are community/template convention. We will ship them as a **template**, which is legitimate — GitHub has templates, it just doesn't document the view names. | +| Users can build custom trigger→action automations | **No.** Only 2 fixed built-in workflows + auto-add + auto-archive. `ProjectV2Workflow` exposes only `id/number/name/enabled`. Automation otherwise goes through **GitHub Actions** via the `project_v2_item` webhook. | +| CSV export | **`.tsv`**, called "Export view data". | +| "Health", "Priority", "Estimate", "Due date" are built-in fields | **No.** `Estimate`/`Priority`/`Health` are *custom* fields users create. There is no `DUE_DATE` in `ProjectV2FieldType`. | +| "Stack by" / nested grouping / collapsed groups | **Not documented.** GraphQL has `groupByFields` + `verticalGroupByFields` but no nesting semantics. Grouping is single-level. | +| Health/insights charts include pie/donut/quadrant | **No.** Exactly 6 layouts: Bar, Column, Stacked bar, Stacked column, Stacked area, Line. | + +**Consequence:** the scope below is *smaller and sharper* than a naive "all GitHub features" list. + +--- + +## 1. Where the code actually is (correction to the premise) + +`plugins/tracker` is **declarations only** (3 files, 925 lines). It is not where the UI is. + +| Package | What it holds | +|---|---| +| `plugins/tracker` | `Issue`/`Project`/`Milestone`/`Component` interfaces + `plugin()` id registry | +| `plugins/tracker-assets` | Icon sprite + 14 locale JSONs | +| **`plugins/tracker-resources`** | **All real UI** — ~250 files, Kanban + Gantt + item drawer | +| `models/tracker` | `TIssue` model, viewlet/column registry, filters, actions, migrations | +| `plugins/view`, `plugins/view-resources` | Generic viewlet host, `FilterBar`, `ViewletSetting`, `ViewOptions` | +| `packages/kanban`, `packages/gantt`, `packages/panel` | Reusable primitives | + +--- + +## 2. Verified gap analysis + +✅ present · ⚠️ partial · ❌ absent + +### 2.1 Views + +| Capability | Status | Where / note | +|---|---|---| +| Table layout | ⚠️ `view.viewlet.Table` exists but bound only to `Project` | register it for `Issue` | +| Board layout | ✅ | `KanbanView.svelte` (570 lines) | +| **Roadmap layout** | ❌ | Huly has a Gantt instead — different thing, no Start/Target field pairing, no markers, no Month/Quarter/Year zoom | +| **View tabs** | ❌ | `ViewletSelector` renders *viewlet types*, not saved views | +| `+ New view` | ❌ `tracker.component.Views` id declared but never registered | `plugin.ts:535` | +| Per-view column set | ❌ one global `ViewletPreference` per viewlet | `ViewletSetting.svelte:455` | +| Per-view filter as a **string** | ❌ stored as JSON `Filter[]` blob | `FilteredView.filters: string` (JSON, not a grammar) | +| **Unsaved-changes dot + Save changes** | ❌ | | +| Duplicate / Rename / Delete / Reorder view | ❌ | | +| Per-view layout switch | ⚠️ layout is bound to the viewlet | | +| Views shared project-wide | ⚠️ `FilteredView` has `sharable` + `users[]` — GitHub has **no personal views** at all | simplify | +| Views scoped to a project | ❌ `attachedTo` is the workspace alias | | + +### 2.2 Fields + +| Capability | Status | Note | +|---|---|---| +| Fixed `Issue` schema | ✅ | `models/tracker/src/types.ts:186-296` | +| **User-defined custom fields** | ❌ **biggest gap** | no `FieldDefinition` doc anywhere in the repo | +| TEXT / NUMBER / DATE custom fields | ❌ | | +| SINGLE_SELECT custom (max 50 options, colors, description) | ❌ | | +| MULTI_SELECT custom | ❌ | | +| ITERATION custom field | ❌ | no `Iteration` class. Dead `{sprint:1}` index at `index.ts:812` | +| Sub-items (parent issue + sub-issue progress) | ✅ strong | `attachedTo` + `parents` + `childInfo` | +| **Hierarchy display** (tree, expand/collapse ≤8 levels, state persists) | ❌ Huly has sub-issue *editing* but no tree *display* with persisted expansion | GA 2026-03-19, default ON for new views | +| Field limits (50 fields/project, 50 options/select) | ❌ | must enforce | +| Field default values for new items | ❌ | shipped 2026-04-09 | +| Org-level shared fields | ❌ | | +| Field reorder + row reorder | ⚠️ `sortable: true` on 1 of 11 viewlets | `viewlets.ts:956` | +| **Field sum** (Σ of number fields, per group) | ❌ | | +| Per-field notifications | ⚠️ hardcoded array | `models/tracker/src/index.ts:194-200` | + +### 2.3 Board + +| Capability | Status | Note | +|---|---|---| +| Column field = any single-select | ⚠️ column = group value of `groupBy[0]` | needs a dedicated `columnField` option | +| Column field = **iteration** field | ❌ | | +| **Swimlanes** (`group by` on a board = horizontal sections) | ❌ | shipped 2023-07-27 | +| Per-column **card limits** (advisory, per view) | ❌ | | +| Show/hide columns | ⚠️ | | +| Card fields governed by the view's field list | ❌ card body hardcoded | `KanbanView.svelte:414-509` | +| "Sorted board ⇒ no manual reorder in column" | ⚠️ Huly writes `rank` only when `orderBy === Manual` | already close | + +### 2.4 Filtering + +| Capability | Status | Note | +|---|---|---| +| Filter bar + chips + 7 per-type editors | ✅ | | +| Keyword search + scope + highlight | ✅ | | +| Filter **grammar**: `field:`, `has:`, `no:`, `is:`, `label:`, `assignee:`, `@me`, `@today`, `@current`, `@next`, `@previous`, `@k±n`, ranges `a..b`, `*` wildcards, `AND`/`OR` + parentheses | ❌ | advanced search GA 2026-07-16 | +| Click a card value to filter | ❌ | | +| `(N) matching items` counter | ⚠️ `resultIssueCountStore` exists | | +| Slice-by panel | ❌ | | + +### 2.5 Item editing + +| Capability | Status | Note | +|---|---|---| +| Item detail drawer | ✅ rich (439 + 268 lines) | | +| Inline cell editors per field | ✅ good | | +| **Spreadsheet-style bulk edit**: copy/paste cells, fill handle, multi-select, `Shift`+arrows, `Delete` to clear, Undo toast | ❌ | Huly bulk actions are ~12 fixed action types | +| Draft items (real project items until converted) | ❌ Huly's `IssueDraft` is transient popup state | | +| Archive (restorable) | ⚠️ `hideArchived` exists | | +| Row height / density | ❌ | shipped 2026-06-25 | +| Item limit 50,000 | ❌ | | + +### 2.6 Iterations + +| Capability | Status | Note | +|---|---|---| +| Iteration field | ❌ | | +| 3 auto-created iterations on field creation | ❌ | | +| Duration in days/weeks, editable name, breaks | ❌ | | +| `@current` / `@next` / `@previous` filter keywords | ❌ | | +| Bulk "Move items to…" | ❌ | | +| Rollups (count, done count, estimate sum) | ⚠️ `IAggregationManager` exists, unused for this | | +| Project **status updates** (`ON_TRACK`/`AT_RISK`/… + body) | ❌ | separate feature | + +### 2.7 Insights + +| Capability | Status | Note | +|---|---|---| +| Charts over project items | ❌ | | +| 6 layouts (Bar, Column, Stacked bar/column/area, Line) | ❌ | | +| Config: X-axis field, Group-by field, Y-axis (Count or Sum/Avg/Min/Max of a number field), filter | ❌ | | +| **Historical charts** (X-axis = Time; Open / Completed / Closed PRs / Not planned; default "Burn up") | ❌ | **blocked — needs a field-value history model; Huly has none** | +| Archived items excluded | ⚠️ | | + +### 2.8 Workflows + +| Capability | Status | Note | +|---|---|---| +| Built-in: issue closed → status Done | ⚠️ Huly has status categories but no such rule | | +| Built-in: PR merged → status Done | ❌ no PR model | stretch | +| Auto-add (repo + filter) | ❌ n/a — no repo concept. Huly analogue: auto-add sub-issues of a type into a project | | +| Auto-archive (filter) | ❌ | | +| `ProjectV2Workflow` doc (name, enabled) | ❌ | | +| `@github-project-automation` attribution | ❌ | `tx.modifiedBy` analogue | +| `project_v2_item` webhook with `changes.field_value` | ❌ | Huly has `TxUpdateDoc` in `DOMAIN_TX` — reusable | +| Custom trigger→action builder | ❌ **and out of scope** — GitHub does not have it | | + +### 2.9 Project-level + +| Capability | Status | Note | +|---|---|---| +| **Per-project roles** (base No access/Read/Write/Admin + collaborators) | ❌ Huly is a flat `members: AccountUuid[]` | | +| Project settings page with field sidebar | ❌ | | +| Project description + Markdown README | ❌ | | +| Project templates (carry views, fields, workflows, insights) | ❌ | | +| Project visibility public/private | ❌ | | +| Tracker-specific permissions | ⚠️ exactly 1 (`ForbidCreateProject`) | | + +### 2.10 What we already have — do not rebuild + +`packages/gantt` (time scale, zoom, viewport, drag, working-days calendar) · +`packages/kanban` (drag columns, rank) · `view.mixin.Groupping` + `IAggregationManager` · +`view.class.FilteredView` (server-persisted; extend it) · `view.mixin.ClassFilters` + 7 filter +components · `ViewletSetting`/`ViewletClassSettings` (column UI) · sub-issue hierarchy + +`ControlPanel` attribute loop · `gantt/lib/scheduler.ts` · 33 Gantt test files as the house +testing pattern · `tests/sanity/tests/tracker/*` for e2e. + +**Huly already exceeds GitHub** (not parity work, protect these from regression): per-item Gantt +rows, dependency arrows (FS/SS/FF/SF + lag), critical path, cascade scheduling with undo, working +days + HR holidays, time-spend reports, PDF/PNG export, kanban swimlane zoom, estimation reports. + +--- + +## 3. Architecture — the seven decisions + +### D1 — Custom fields: registry doc + shadow record on the item + +**Constraint:** the model is **TxModel-fixed**. `core.class.Attribute` docs are generated by the +model build and pushed via `TxModel` at server upgrade — you cannot create an attribute at +runtime. So a user-defined field can never be a real `Attribute`. (The `custom*` convention in +`ViewletSetting.svelte:230` and `converter-resources/*` concerns derived/exported column *labels* +and is not a runtime-attribute mechanism.) + +**Design:** + +``` +tracker.class.ProjectField (DOMAIN_TRACKER, space = the Project) + label: string + key: string // generated slug, unique per project, e.g. "storyPoints" + type: ProjectFieldType // Text | Number | Date | SingleSelect | MultiSelect | Iteration + position: number + project: Ref + description?: string + defaultValue?: any // Text/Number/SingleSelect only — Date does NOT support it (GitHub parity) + // SingleSelect / MultiSelect: + options?: ProjectFieldOption[] // { value, label, color, description } max 50 + +tracker.class.Issue += @Prop(TypeRecord()) customFields: Record +``` + +Exactly the **6** `ProjectV2CustomFieldType` values. No checkbox, no people, no URL — those do not +exist in GitHub Projects. + +**Options considered** + +| Option | Pros | Cons | Verdict | +|---|---|---|---| +| **A. `Record` prop on `Issue`** (chosen) | 1 prop, 1 migration, values ride every query, notifications/aggregators read them, presenter is trivial | not server-indexable per key → custom-field filter/sort/group runs client-side | ✅ v1 | +| B. `IssueFieldValue` AttachedDoc collection | server-indexable, scales past 100k | 1 query per field read, N per row to hydrate, breaks the single-fetch query model | ⏳ scale path | +| C. Shadow scalars `custom_` on `Issue` | Mongo-indexable | one global index per key, impossible to create at runtime; unbounded prop sprawl | ❌ | + +**Mitigation for A's limit:** a configured ceiling `TRACKER_CUSTOM_FIELD_SCAN_LIMIT` (default +5000). Above it, custom-field filter/sort/group is **disabled with an explicit UI error**, never +silently truncated. Fail fast, not a silent throttle. + +**Enforce at the model level:** ≤50 fields per project, ≤50 options per select field. +Where? A server trigger on `ProjectField` create — the last line of defence against a bad client. + +**Plugs in at** +1. `models/tracker/src/projectField.ts` — `ProjectField` + `ProjectFieldOption` (new file, keeps + `types.ts` from bloating) +2. `TIssue.customFields` prop + migration in `models/tracker/src/migration.ts` +3. `tracker.aggregation.ProjectFieldRegistry` — an `IAggregationManager`-shaped resource so + presenters resolve key→definition without each re-querying +4. `CustomFieldPresenter.svelte` reads `issue.customFields?.[field.key]` +5. Per-type editors: `Text`, `Number`, `Date`, `SingleSelect`, `MultiSelect`, `Iteration` +6. Detail panel: the generic attribute loop at `ControlPanel.svelte:236-266` renders it free +7. `CustomFieldFilter.svelte` + `tracker.function.BuildCustomFieldQuery` → client-side predicate + +### D2 — Saved views: extend `FilteredView`, do not build a parallel concept + +GitHub views are **project-wide and shared** — "when a view is saved, anyone who opens the project +will see the saved view". There are **no personal views**. So drop the personal/shared split. + +```ts +// plugins/view/src/types.ts:110 — additive fields only +export interface FilteredView extends Doc { + // ...existing: name, location, filters, viewOptions, filterClass, viewletId, sharable, users, createdBy, attachedTo + project?: Ref // NEW — scope to a project space + config?: (BuildModelKey | string)[] // NEW — ordered visible field list (GitHub: visibleFieldIds) + cardProperties?: string[] // NEW — board: which fields show on cards + descriptor?: Ref // NEW — the layout (table | board | roadmap) + columnField?: string // NEW — board column field + order?: number // NEW — tab order (GraphQL: POSITION) + icon?: Asset // NEW + filterText?: string // NEW — GitHub's view filter is a STRING (see D3) + groupBy?: string[] // NEW — GitHub stores grouping on the view, not in localStorage + sortBy?: [string, SortingOrder][] // NEW + fieldSum?: string[] // NEW + sliceField?: string // NEW + hierarchy?: boolean // NEW — show sub-issue tree + rowHeight?: RowHeight // NEW — Single|Medium|Tall|ExtraTall + columnLimits?: Record // NEW — advisory per-column caps + dirty?: boolean // NEW — unsaved-changes dot +} +``` + +**Deliberate removal:** personal/shared distinction. Keep the fields for back-compat but stop +using them; new views are always shared. Do not add per-view ACLs — GitHub has none. + +**Two-sources-of-truth problem (critical):** `ViewOptions` is persisted in **localStorage** +(`view-resources/src/viewOptions.ts:47-62`) while `FilteredView` is a **server doc**. +Rule: **an active saved view is authoritative**; localStorage is the fallback for ad-hoc state. +Expose exactly one accessor, `getEffectiveViewConfig()`, that all views read. Otherwise this +will produce drift bugs that are very hard to debug. + +**UI:** `plugins/view-resources/src/components/view/SavedViewBar.svelte` (tab bar + `+ New view` + +per-tab kebab: rename / duplicate / delete / drag-to-reorder + unsaved dot) and +`CreateViewPopup.svelte`. + +**Template:** seed GitHub-style views (`Current iteration`, `Next iteration`, `Prioritized +backlog`, `Roadmap`, `In review`, `My items`) via an `OnProjectCreate` trigger. Be honest in the +code comment: these are *our* template names, not GitHub defaults (GitHub ships none). + +### D3 — Adopt GitHub's filter grammar, compile to `DocumentQuery` + +This is the highest-leverage decision. GitHub stores a view's filter as a **string** in a real +grammar; Huly stores a JSON blob of `Filter[]`. Porting the grammar gives us, for free: +`field:` · `has:` · `no:` · `is:` · `label:` · `assignee:` · `milestone:` · `type:` · +`sub-issues.is:` · `@me` · `@today[-Nd]` · `@current` · `@next` · `@previous` · `@k±n` +arithmetic · `>` `>=` `<` `<=` · inclusive ranges `a..b` with `*` wildcards · `title:"..."` · +comma = OR, repeat = AND, quoted values · and `AND`/`OR` + parentheses (GA 2026-07-16). + +Design: **one grammar, two consumers** — a client compiler string → `DocumentQuery` +for server-side fetch, and a client-side evaluator for custom fields (D1) which the server cannot +index. Both share the tokenizer and the operator semantics, so they cannot drift. + +Deliver as a small, well-tested module: `plugins/view-resources/src/filter/grammar/{tokenizer,parser,compile,evaluate}.ts` +with jest tests. Reusable by every future view — this is the DRY seam. + +Note the documented GitHub limitation we should *beat*: OR across fields was unsupported until +2026-07-16. We should support it from day 1. + +### D4 — Iterations + +``` +tracker.class.Iteration (DOMAIN_TRACKER) + project: Ref + field: Ref // owning iteration field + label: string + number: number + startDate: Timestamp + duration: number // days (GitHub: duration + startDay, same idea) + status: IterationStatus // Planned | Active | Closed + isBreak?: boolean +``` + +Plus `@Prop(TypeRef(Iteration)) iteration?: Ref | null` + `@Index(IndexKind.Indexed)` +on `TIssue` and an index entry `{iteration: 1}`. + +- Creating an Iteration **field** auto-creates **3 iterations** (GitHub parity). +- `@current` / `@next` / `@previous` resolve against `startDate` + `duration`. +- Breaks supported (a break is an iteration with `isBreak`). +- Iteration is usable as a **board column field** and as the **Roadmap Start/Target date source**. +- Rollups via the existing `IAggregationManager` seam. + +Iteration fields are self-contained (no Gantt/Kanban dependency) so Insights and Workload can use +them too. Do **not** subclass `Milestone` — Milestone has different semantics. + +### D5 — Roadmap as the third layout + +GitHub's Roadmap is **not** a Gantt. It is a single high-level bar chart: +- **Start date** + **Target date** fields chosen per view; each may be a **date field or an + iteration field** +- **Markers**: vertical lines for iterations, item dates, milestones (togglable) +- **Zoom**: Month / Quarter / Year +- Drag an item → changes its dates or its iteration +- Group by / Sort / Slice by / Field sum all behave as in Table +- **No per-item rows, no dependency arrows** + +Build it as a new `ViewletDescriptor` + component. **Reuse** `packages/gantt/time-scale.ts` for +the Month/Quarter/Year math and viewport; **do not** reuse `GanttView.svelte` (4893 lines — do not +grow it) and do not reuse `lib/build-rows.ts` (it builds per-item rows, the opposite of what +Roadmap wants). New files only. + +The existing Gantt stays as a Huly-only extra view. Roadmap and Gantt are separate descriptors +with separate icons so users can tell them apart. + +### D6 — Insights, and the history problem + +Chart model: 6 layouts (Bar, Column, Stacked bar, Stacked column, Stacked area, Line). Config: +layout, X-axis field, Group-by field, Y-axis (`Count` or `Sum`/`Average`/`Minimum`/`Maximum` of a +number field), filter string (D3 grammar). Archived items excluded. + +**The blocker:** *historical* charts set X-axis = Time and need series `Open` / `Completed` / +`Closed PRs` / `Not planned` (default "Burn up"). Huly has **no field-value history** — `TxUpdateDoc` +lives in `DOMAIN_TX` and is not designed to be queried as a time series over arbitrary fields. + +Options: +- **A.** Query `DOMAIN_TX` for `TxUpdateDoc` on `tracker.class:Issue` and replay. Cheap to build, + but `DOMAIN_TX` is the transaction log — queryable but unindexed for this shape, and long + retention is not guaranteed. Fine for MVP; will degrade. +- **B.** Snapshot model: a nightly/rolling `tracker.class.IssueFieldSnapshot` collection + (issue × date × field values). Correct, bounded, queryable, but adds real storage and a + backfill story. +- **C.** Skip historical charts in v1; ship current charts only, honestly. + +**Recommendation:** C for the first ship, then B if the burn-up chart proves valuable. Do **not** +ship A pretending it is B. This is the one place where honest scope reduction beats a clever +implementation. + +UI: `InsightsPanel` reached from a graph icon in the project header (matching the screenshot's +`Insights` button). Note GitHub has **no API for charts** — charts are UI-only. We get to choose; +persisting chart configs as docs is strictly better and cheap. + +### D7 — Workflows: built-ins only, not a builder + +GitHub ships no custom trigger→action builder. So: + +``` +tracker.class.Workflow (DOMAIN_TRACKER) + project, name, enabled: boolean + kind: WorkflowKind // SetStatusDoneOnClose | MoveSubIssuesIntoProject | AutoArchive + config?: Record +``` + +- **`SetStatusDoneOnClose`** — when an Issue's `status` category becomes `done`/`canceled`, ensure + the Done status option is set. Semantically near-trivial in Huly (status *is* the field), so this + reduces to "optionally auto-set the Done status when the underlying issue is closed". +- **`AutoArchive`** — a filter string (D3 grammar) + cadence; archives matching items. Archived + items keep all field values and stay restorable (`hideArchived` already exists). +- **`AutoAddFromQuery`** — Huly's analogue of GitHub's repo-scoped auto-add: a filter string that + pulls matching issues into the project. No repo concept needed. +- Attribution: automation tx carry a marker so timelines show the change as automated + (`tx.modifiedBy` analogue of `@github-project-automation`). + +**Explicitly out of scope:** a general trigger→action builder. If we want it, GitHub Actions + +a `project_v2_item`-equivalent webhook is the parity-faithful route — Huly already writes +`TxUpdateDoc` into `DOMAIN_TX`, so a webhook on field-value change is a much smaller lift than +a rule engine, and it gives users the full power of code. **Recommend the webhook over the +builder**, and say so in the plan. + +--- + +## 4. Phasing + +Each phase ships independently and ends green. **No phase starts before the previous phase's +`rushx build` + `rushx _phase:validate` + unit tests pass.** + +| # | Phase | Ships | Why this order | +|---|---|---|---| +| **0** | **Custom-field foundation** | `ProjectField` doc, `customFields` record, registry resource, CRUD UI, editors, detail-panel rendering, limits enforced | Load-bearing. Insights, Workload, field-sum, per-view columns and workflows all aggregate over fields. Nothing else is worth building first. | +| **1** | **Custom fields as columns** | Optional columns, filter, client-side sort, group-by-select, `sortable: true` for column drag-reorder | Makes fields usable in views | +| **2** | **Saved views + tabs** | `FilteredView` extension, `SavedViewBar`, `+ New view`, per-view columns, unsaved dot, rename/duplicate/delete/reorder, `getEffectiveViewConfig()`, default-view template | The single biggest UX jump; unblocks per-view everything | +| **3** | **Filter grammar** | Tokenizer/parser/compiler/evaluator, `@`-keywords, ranges, wildcards, AND/OR + parens, click-value-to-filter, `field:` addressing | Unblocks per-view filters and auto-archive | +| **4** | **Spreadsheet-style bulk edit** | Cell multi-select, `Shift`+arrows, copy/paste, fill handle, `Delete` to clear, Undo toast, row + column drag-reorder, row height | Table becomes genuinely spreadsheet-like | +| **5** | **Iterations** | Iteration field, 3 auto-created, duration/breaks, `@current/@next/@previous`, board column field, rollups, bulk move | Powers `Current iteration` / `Next iteration` views | +| **6** | **Roadmap layout** | New descriptor + component, Start/Target field pairing, markers, Month/Quarter/Year zoom, drag-to-reschedule | Completes the 3-layout parity set | +| **7** | **Board parity** | Column field (select *or* iteration), swimlanes via group-by, per-column advisory limits, show/hide columns, card fields from the view's field list | Depends on 2 + 5 | +| **8** | **Hierarchy + slice + field sum** | Sub-issue tree display ≤8 levels with persisted expansion (default on), slice-by panel, Σ number fields per group | Depends on 2 | +| **9** | **Insights (current charts only)** | 6 layouts, config panel, persisted chart docs, archived excluded | Honest v1; historical/burn-up is a separate decision (D6) | +| **10** | **Workflows (built-ins)** | `Workflow` doc, Done-on-close, auto-archive, auto-add-by-query, automation attribution, field-change webhook | Deliberately last — it writes data | +| **11** | **Project-level** | Settings page with field sidebar, description + README, templates, status updates | Workspace-level roles only (decision 5) | + +**Recommended first slice: Phases 0 + 1.** Time-box Phase 0 to a 2–3 day spike to validate D1 end +to end (create field → set value → read in detail panel → render as column) before committing to +the roadmap. + +**Scope extras (reinstated by the user, 2026-10-03):** Calendar view, Workload view, nested/stacked +grouping ("stack by"), and a custom trigger->action automation builder were first dropped because +GitHub Projects does not have them. The user decided they **stay in scope**: the tracker should be +at least as good as the GitHub board. They are scheduled as phases 12-15 *after* GitHub parity +(phases 0-11) so they never compete with parity work. Still out of scope: per-field permissions, +personal views, pie/donut charts, historical insights / burn-up (see D6). + +| # | Extra phase | Notes | +|---|---|---| +| **12** | Calendar layout | Date-field driven, reuses saved-view infrastructure from phase 2 | +| **13** | Workload layout | Per-assignee capacity vs. Estimate/Iteration, depends on phases 0 and 5 | +| **14** | Nested grouping | Multi-level group-by in Table and Board; extends the group-by from phases 1 and 7 | +| **15** | Automation builder | Trigger->action rules on top of the phase 10 workflow doc; field-change webhook ships in phase 10 | + +--- + +## 5. Files this touches + +**New packages:** none. `models/tracker` additions only, unless Phase 6/9 forces a split. + +**New files (representative)** + +``` +models/tracker/src/projectField.ts # ProjectField + ProjectFieldOption +models/tracker/src/iteration.ts # Iteration +models/tracker/src/workflow.ts # Workflow + kinds +plugins/tracker/src/projectField.ts # interfaces + ProjectFieldType enum (6 values) +plugins/tracker/src/iteration.ts +plugins/tracker/src/workflow.ts +plugins/tracker-resources/src/projectFields/{registry,CrudPopup,optionsEditor}.ts|svelte +plugins/tracker-resources/src/projectFields/editors/{Text,Number,Date,SingleSelect,MultiSelect,Iteration}Editor.svelte +plugins/tracker-resources/src/components/roadmap/{RoadmapView,RoadmapMarkers,RoadmapToolbar}.svelte +plugins/tracker-resources/src/components/insights/{InsightsPanel,ChartConfigPanel,charts/*}.svelte +plugins/tracker-resources/src/components/workflows/{WorkflowsPopup,WorkflowEditor}.svelte +plugins/tracker-resources/src/components/iterations/{IterationBrowser,IterationEditor,IterationSelector,IterationRollupPresenter}.svelte +plugins/view-resources/src/components/view/{SavedViewBar,CreateViewPopup,ViewTab,CardFieldRenderer}.svelte +plugins/view-resources/src/filter/grammar/{tokenizer,parser,compile,evaluate}.ts # D3 — pure, heavily tested +plugins/view-resources/src/utils/nested-groups.ts # keep (harmless) or drop +server-plugins/tracker-resources/src/project/on-project-create.ts # seed default views +server-plugins/tracker-resources/src/workflow/{on-workflow-evaluate,on-field-change}.ts +``` + +**Modified (hot spots — expect conflict risk)** + +- `models/tracker/src/types.ts` — `+customFields`, `+iteration`, `ProjectField`/`Iteration`/`Workflow` classes +- `models/tracker/src/viewlets.ts` — Roadmap descriptor, `issueConfig`, group/sort/filter whitelists +- `models/tracker/src/migration.ts` — `customFields` backfill, `iteration` backfill +- `models/tracker/src/index.ts` — indexes, notification types, permissions, limits trigger +- `models/tracker/src/plugin.ts` — ids + `IntlString`s +- `models/view/src/index.ts` + `plugins/view/src/types.ts` — `FilteredView` +- `plugins/view-resources/src/viewOptions.ts` — `getEffectiveViewConfig()` precedence rule +- `plugins/tracker-resources/src/components/issues/KanbanView.svelte` — column field, swimlanes, card refactor +- `plugins/tracker-resources/src/components/issues/IssuesView.svelte` — `SavedViewBar` in header +- `plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte` — custom-field rows +- `plugins/tracker-assets/lang/*.json` — 14 locales; key parity enforced by an existing test + +--- + +## 6. Risks + +| # | Risk | Sev | Mitigation | +|---|---|---|---| +| 1 | **TxModel-fixed schema** blocks runtime attributes | 🔴 blocking | D1 record-on-item; field *definitions* are data | +| 2 | Custom-field filter/sort/group must be client-side | 🔴 high | Configurable `TRACKER_CUSTOM_FIELD_SCAN_LIMIT`; fail fast above it | +| 3 | `ViewOptions` in **localStorage** vs `FilteredView` on the **server** = two sources of truth | 🔴 high | Explicit precedence + one `getEffectiveViewConfig()` accessor | +| 4 | Scope creep into non-GitHub features (calendar, workload, rule engine) | 🔴 high | §0 lists what was dropped and why; Phase budget table has none of them | +| 5 | Historical Insights charts need a model that does not exist | 🟠 med-high | D6: ship current charts, decide B (snapshots) separately. Never fake it with `DOMAIN_TX` replay | +| 6 | Modifying `models/view` (`FilteredView`) touches every plugin | 🔴 high | Additive fields + migration; full `rush build` before merge | +| 7 | `KanbanView.svelte` card body hardcoded | 🟡 med | Refactor in Phase 2 before card config lands in Phase 7 | +| 8 | 14-locale key parity enforced by tests | 🟡 med | Every `IntlString` added to all locales in the same change | +| 9 | `IssuesView.svelte` deliberately never unmounts the viewlet (virtual-scroller starvation) | 🟡 med | `SavedViewBar` must swap via the existing `viewlet` binding — no overlay mount | +| 10 | `GanttView.svelte` is 4893 lines | 🟡 med | Do not grow it. Roadmap is new files; only time-scale moves to `packages/gantt` | +| 11 | Automations write data | 🟠 med-high | Built-ins only, attribute to a system actor, cap per run, dry-run flag in config | +| 12 | ~~Per-project roles~~ dropped (workspace-level only) | - | No `core` permission changes | +| 13 | Unmeasured phase estimates | 🟡 med | Phase 0 is a time-boxed spike; re-estimate after | + +--- + +## 7. Validation strategy (per phase) + +```bash +cd && rushx test # unit tests +cd && rushx build && rushx _phase:validate # the required gate +rush build --to # cross-package type check before PR +# NEVER run rushx format automatically — repo rule, it can corrupt files +``` + +- Pure logic (grammar, nested groups, query compiler, rollups, limit guards) gets a jest file + next to it, matching `gantt/lib/__tests__/`. +- New layouts get an e2e spec in `tests/sanity/tests/tracker/`. +- Locale parity is already covered by `plugins/tracker-assets/src/__tests__/lang*.test.ts` — it + fails the build if a new `IntlString` misses a locale. Use it as a checklist. +- Model changes need a `@Migration` step in `models/tracker/src/migration.ts` **and** a test in + `models/tracker/src/__tests__/migration.test.ts` (existing pattern to copy). + +--- + +## 8. Decisions (resolved by the user, 2026-10-03) + +1. **Scope** - Calendar, Workload, nested grouping and the automation builder **stay in scope** + (phases 12-15, after parity). Goal: the tracker alone is as good as a GitHub board. +2. **Default view names** - follow GitHub Projects behaviour (a new project starts with one view of + the chosen layout). Named views (`Current iteration`, etc.) are an optional template only. +3. **Project size** - follow GitHub Projects limits (50,000 items per project, 50 custom fields, + Insights/field limits as in GitHub). Option B (separate collection) is not needed at launch; + enforce these limits and set `TRACKER_CUSTOM_FIELD_SCAN_LIMIT` accordingly. +4. **Historical insights / burn-up** - out of scope (maybe later). +5. **Roles** - workspace-level only. Per-project roles are dropped from phase 11. +6. **Automation** - field-change webhook first (phase 10); rule builder later (phase 15). +7. **Merge strategy** - one large PR. Phases are still built and validated sequentially on this + branch (build + `_phase:validate` + tests green before the next phase starts). + +--- + +*Append-only. Next entry should record the Phase 0 spike result.* \ No newline at end of file diff --git a/plugins/tracker/src/__tests__/project-field.test.ts b/plugins/tracker/src/__tests__/project-field.test.ts new file mode 100644 index 0000000000..e416debe48 --- /dev/null +++ b/plugins/tracker/src/__tests__/project-field.test.ts @@ -0,0 +1,98 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + MAX_PROJECT_FIELDS, + MAX_PROJECT_FIELD_OPTIONS, + ProjectFieldType, + generateFieldKey, + getFieldValue, + normalizeFieldValue, + overLimitFields, + slugifyFieldLabel, + validateProjectField +} from '../projectField' + +const opts = [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' } +] + +describe('slugifyFieldLabel', () => { + it('camel-cases words', () => expect(slugifyFieldLabel('Story points')).toBe('storyPoints')) + it('strips punctuation', () => expect(slugifyFieldLabel(' Due -- date! ')).toBe('dueDate')) + it('prefixes leading digits', () => expect(slugifyFieldLabel('2nd pass')).toBe('f2ndPass')) + it('returns empty for symbols only', () => expect(slugifyFieldLabel('!!!')).toBe('')) +}) + +describe('generateFieldKey', () => { + it('avoids collisions', () => expect(generateFieldKey('Size', ['size', 'size2'])).toBe('size3')) + it('keeps free key', () => expect(generateFieldKey('Size', [])).toBe('size')) +}) + +describe('validateProjectField', () => { + it('rejects empty label', () => + expect(validateProjectField({ label: ' ', type: ProjectFieldType.Text }, [])).toBe('emptyLabel')) + it('rejects duplicate label case-insensitively', () => + expect(validateProjectField({ label: 'size', type: ProjectFieldType.Text }, [{ label: 'Size' }])).toBe( + 'duplicateLabel' + )) + it('enforces the field limit', () => { + const existing = Array.from({ length: MAX_PROJECT_FIELDS }, (_, i) => ({ label: `f${i}` })) + expect(validateProjectField({ label: 'x', type: ProjectFieldType.Text }, existing)).toBe('tooManyFields') + }) + it('requires options for selects', () => + expect(validateProjectField({ label: 'x', type: ProjectFieldType.SingleSelect }, [])).toBe('optionsRequired')) + it('enforces the option limit', () => { + const options = Array.from({ length: MAX_PROJECT_FIELD_OPTIONS + 1 }, (_, i) => ({ value: `${i}`, label: `o${i}` })) + expect(validateProjectField({ label: 'x', type: ProjectFieldType.MultiSelect, options }, [])).toBe( + 'tooManyOptions' + ) + }) + it('rejects duplicate options', () => + expect( + validateProjectField( + { label: 'x', type: ProjectFieldType.SingleSelect, options: [...opts, { value: 'c', label: 'a' }] }, + [] + ) + ).toBe('duplicateOption')) + it('forbids default on date', () => + expect(validateProjectField({ label: 'x', type: ProjectFieldType.Date, defaultValue: 1 }, [])).toBe( + 'defaultNotAllowed' + )) + it('checks default against options', () => + expect( + validateProjectField( + { label: 'x', type: ProjectFieldType.SingleSelect, options: opts, defaultValue: 'zzz' }, + [] + ) + ).toBe('invalidDefault')) + it('accepts a valid number field', () => + expect(validateProjectField({ label: 'x', type: ProjectFieldType.Number, defaultValue: 3 }, [])).toBeUndefined()) +}) + +describe('normalizeFieldValue', () => { + it('drops removed select options', () => + expect(normalizeFieldValue({ type: ProjectFieldType.SingleSelect, options: opts }, 'gone')).toBeNull()) + it('filters multi-select to known options', () => + expect(normalizeFieldValue({ type: ProjectFieldType.MultiSelect, options: opts }, ['a', 'gone'])).toEqual(['a'])) + it('rejects NaN numbers', () => expect(normalizeFieldValue({ type: ProjectFieldType.Number }, NaN)).toBeNull()) + it('rejects wrong types', () => expect(normalizeFieldValue({ type: ProjectFieldType.Text }, 5)).toBeNull()) +}) + +describe('getFieldValue', () => { + it('reads by key and tolerates missing record', () => { + const f = { key: 'size', type: ProjectFieldType.Number } + expect(getFieldValue({ size: 5 }, f)).toBe(5) + expect(getFieldValue(undefined, f)).toBeNull() + }) +}) + +describe('overLimitFields', () => { + it('keeps the oldest MAX fields', () => { + const fields = Array.from({ length: MAX_PROJECT_FIELDS + 2 }, (_, i) => ({ _id: i, position: i, createdOn: i })) + expect(overLimitFields(fields).map((f) => f._id)).toEqual([MAX_PROJECT_FIELDS, MAX_PROJECT_FIELDS + 1]) + }) +}) diff --git a/plugins/tracker/src/index.ts b/plugins/tracker/src/index.ts index 6636bacb1f..14f28a2ae4 100644 --- a/plugins/tracker/src/index.ts +++ b/plugins/tracker/src/index.ts @@ -49,8 +49,10 @@ import { } from '@hcengineering/task' import { AnyComponent, ComponentExtensionId, Location, ResolvedLocation } from '@hcengineering/ui' import { Action, ActionCategory, IconProps } from '@hcengineering/view' +import type { ProjectField } from './projectField' export * from './analytics' +export * from './projectField' /** * @public @@ -279,6 +281,9 @@ export interface Issue extends Task { milestone?: Ref | null + // Values of user-defined project fields, keyed by ProjectField.key + customFields?: Record + // Estimation in man hours estimation: number @@ -575,6 +580,7 @@ const pluginState = plugin(trackerId, { IssueStatus: '' as Ref>, TypeIssuePriority: '' as Ref>>, Milestone: '' as Ref>, + ProjectField: '' as Ref>, TypeMilestoneStatus: '' as Ref>>, TimeSpendReport: '' as Ref>, TypeReportedTime: '' as Ref>>, diff --git a/plugins/tracker/src/projectField.ts b/plugins/tracker/src/projectField.ts new file mode 100644 index 0000000000..c4fd640d4a --- /dev/null +++ b/plugins/tracker/src/projectField.ts @@ -0,0 +1,206 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Doc, Ref } from '@hcengineering/core' +import type { Project } from './index' + +/** + * Custom field kinds, mirroring GitHub's `ProjectV2CustomFieldType`. + * @public + */ +export enum ProjectFieldType { + Text = 'text', + Number = 'number', + Date = 'date', + SingleSelect = 'singleSelect', + MultiSelect = 'multiSelect', + Iteration = 'iteration' +} + +/** + * @public + */ +export interface ProjectFieldOption { + // Stable id stored in issue.customFields; never changes when the label is renamed + value: string + label: string + color?: number + description?: string +} + +/** + * User-defined field of a tracker project. Values live in `Issue.customFields[key]`. + * @public + */ +export interface ProjectField extends Doc { + space: Ref + label: string + // Slug, unique per project + key: string + type: ProjectFieldType + position: number + description?: string + // Not supported for Date fields (GitHub parity) + defaultValue?: string | number | null + // SingleSelect / MultiSelect only + options?: ProjectFieldOption[] +} + +/** + * Value kinds stored in `Issue.customFields`. + * @public + */ +export type ProjectFieldValue = string | number | string[] | null + +/** @public */ +export const MAX_PROJECT_FIELDS = 50 +/** @public */ +export const MAX_PROJECT_FIELD_OPTIONS = 50 + +/** + * Turn a label into a camelCase key, e.g. "Story points" -> "storyPoints". + * Returns an empty string when the label has no alphanumerics. + * @public + */ +export function slugifyFieldLabel (label: string): string { + const words = label + .normalize('NFKD') + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .split(/\s+/) + .filter((w) => w.length > 0) + if (words.length === 0) return '' + const [first, ...rest] = words + const slug = [first.toLowerCase(), ...rest.map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase())].join('') + // Keys must not start with a digit so they stay usable as property names + return /^\d/.test(slug) ? `f${slug}` : slug +} + +/** + * Pick a key based on the label that does not collide with `taken`. + * @public + */ +export function generateFieldKey (label: string, taken: Iterable): string { + const used = new Set(taken) + const base = slugifyFieldLabel(label) + if (base === '') return '' + if (!used.has(base)) return base + let i = 2 + while (used.has(`${base}${i}`)) i++ + return `${base}${i}` +} + +/** + * @public + */ +export type ProjectFieldValidationError = + | 'emptyLabel' + | 'duplicateLabel' + | 'tooManyFields' + | 'tooManyOptions' + | 'duplicateOption' + | 'emptyOption' + | 'optionsRequired' + | 'defaultNotAllowed' + | 'invalidDefault' + +/** + * Validate a field definition against the other fields of the same project. + * `existing` must not include the field being edited. + * @public + */ +export function validateProjectField ( + field: Pick, + existing: Array> +): ProjectFieldValidationError | undefined { + const label = field.label.trim() + if (label === '') return 'emptyLabel' + if (existing.length >= MAX_PROJECT_FIELDS) return 'tooManyFields' + if (existing.some((f) => f.label.trim().toLowerCase() === label.toLowerCase())) return 'duplicateLabel' + + const isSelect = field.type === ProjectFieldType.SingleSelect || field.type === ProjectFieldType.MultiSelect + if (isSelect) { + const options = field.options ?? [] + if (options.length === 0) return 'optionsRequired' + if (options.length > MAX_PROJECT_FIELD_OPTIONS) return 'tooManyOptions' + const seen = new Set() + for (const o of options) { + const l = o.label.trim().toLowerCase() + if (l === '') return 'emptyOption' + if (seen.has(l)) return 'duplicateOption' + seen.add(l) + } + } + + const def = field.defaultValue + if (def !== undefined && def !== null) { + switch (field.type) { + case ProjectFieldType.Text: + if (typeof def !== 'string') return 'invalidDefault' + break + case ProjectFieldType.Number: + if (typeof def !== 'number' || !Number.isFinite(def)) return 'invalidDefault' + break + case ProjectFieldType.SingleSelect: + if (typeof def !== 'string' || !(field.options ?? []).some((o) => o.value === def)) return 'invalidDefault' + break + default: + return 'defaultNotAllowed' + } + } + return undefined +} + +/** + * Coerce an arbitrary stored value to the shape the field type expects. + * Returns `null` for anything that does not fit, so stale values (e.g. a removed + * select option) never crash a presenter. + * @public + */ +export function normalizeFieldValue (field: Pick, value: unknown): ProjectFieldValue { + if (value === undefined || value === null) return null + switch (field.type) { + case ProjectFieldType.Text: + return typeof value === 'string' ? value : null + case ProjectFieldType.Number: + return typeof value === 'number' && Number.isFinite(value) ? value : null + case ProjectFieldType.Date: + return typeof value === 'number' && Number.isFinite(value) ? value : null + case ProjectFieldType.SingleSelect: + return typeof value === 'string' && (field.options ?? []).some((o) => o.value === value) ? value : null + case ProjectFieldType.MultiSelect: { + if (!Array.isArray(value)) return null + const valid = new Set((field.options ?? []).map((o) => o.value)) + const kept = value.filter((v): v is string => typeof v === 'string' && valid.has(v)) + return kept.length > 0 ? kept : null + } + case ProjectFieldType.Iteration: + return typeof value === 'string' ? value : null + } +} + +/** + * Read one field value from an issue's `customFields` record. + * @public + */ +export function getFieldValue ( + customFields: Record | undefined, + field: Pick +): ProjectFieldValue { + return normalizeFieldValue(field, customFields?.[field.key]) +} + +/** + * Return the part of `fields` that exceeds the per-project limit, oldest kept. + * Used by the server to drop fields created by a client that bypassed the UI check. + * @public + */ +export function overLimitFields & { _id: unknown, createdOn?: number }> ( + fields: T[] +): T[] { + if (fields.length <= MAX_PROJECT_FIELDS) return [] + const sorted = [...fields].sort((a, b) => (a.createdOn ?? 0) - (b.createdOn ?? 0)) + return sorted.slice(MAX_PROJECT_FIELDS) +} From 4375c80e31f05270ee2e9ab92b987001dd6ca3fe Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:28:22 +0530 Subject: [PATCH 13/32] feat(tracker): add ProjectField model class and Issue.customFields Co-Authored-By: Claude Sonnet 5.5 --- models/tracker/src/index.ts | 2 ++ models/tracker/src/plugin.ts | 7 +++++ models/tracker/src/types.ts | 40 +++++++++++++++++++++++++++++ plugins/tracker-assets/lang/en.json | 7 +++++ plugins/tracker-assets/lang/ru.json | 7 +++++ 5 files changed, 63 insertions(+) diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index e785d30409..8e0e6ca53f 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -47,6 +47,7 @@ import { TIssueTypeData, TMilestone, TProject, + TProjectField, TProjectTargetPreference, TRelatedIssueTarget, TTimeSpendReport, @@ -479,6 +480,7 @@ export function createModel (builder: Builder): void { TIssueStatus, TTypeIssuePriority, TMilestone, + TProjectField, TTypeMilestoneStatus, TTimeSpendReport, TTypeReportedTime, diff --git a/models/tracker/src/plugin.ts b/models/tracker/src/plugin.ts index 89d904ac03..61e176a657 100644 --- a/models/tracker/src/plugin.ts +++ b/models/tracker/src/plugin.ts @@ -27,6 +27,13 @@ import { type Application } from '@hcengineering/workbench' export default mergeIds(trackerId, tracker, { string: { + ProjectField: '' as IntlString, + ProjectFields: '' as IntlString, + CustomFields: '' as IntlString, + FieldKey: '' as IntlString, + FieldType: '' as IntlString, + FieldDefaultValue: '' as IntlString, + FieldOptions: '' as IntlString, Projects: '' as IntlString, GotoIssues: '' as IntlString, GotoActive: '' as IntlString, diff --git a/models/tracker/src/types.ts b/models/tracker/src/types.ts index 853e19450c..63566ddfa7 100644 --- a/models/tracker/src/types.ts +++ b/models/tracker/src/types.ts @@ -71,6 +71,9 @@ import { type IssueTemplate, type IssueTemplateChild, type Milestone, + type ProjectField, + type ProjectFieldOption, + ProjectFieldType, type MilestoneStatus, type Project, type RelatedClassRule, @@ -243,6 +246,10 @@ export class TIssue extends TTask implements Issue { @ReadOnly() declare space: Ref + @Prop(TypeRecord(), tracker.string.CustomFields) + @Hidden() + customFields?: Record + @Prop(TypeDate(DateRangeMode.DATETIME), tracker.string.IssueStartDate) @Index(IndexKind.Indexed) declare startDate: Timestamp | null @@ -462,6 +469,39 @@ export class TMilestone extends TDoc implements Milestone { declare space: Ref } +/** + * @public + */ +@Model(tracker.class.ProjectField, core.class.Doc, DOMAIN_TRACKER) +@UX(tracker.string.ProjectField, tracker.icon.Issues, '', 'label', undefined, tracker.string.ProjectFields) +export class TProjectField extends TDoc implements ProjectField { + @Prop(TypeString(), tracker.string.Title) + label!: string + + @Prop(TypeString(), tracker.string.FieldKey) + @ReadOnly() + key!: string + + @Prop(TypeString(), tracker.string.FieldType) + @ReadOnly() + type!: ProjectFieldType + + @Prop(TypeNumber(), tracker.string.Number) + @Hidden() + position!: number + + @Prop(TypeString(), tracker.string.Description) + description?: string + + @Prop(TypeRecord(), tracker.string.FieldDefaultValue) + defaultValue?: string | number | null + + @Prop(ArrOf(TypeRecord()), tracker.string.FieldOptions) + options?: ProjectFieldOption[] + + declare space: Ref +} + @UX(core.string.Number) @Model(tracker.class.TypeReportedTime, core.class.Type) export class TTypeReportedTime extends TType {} diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index d9dc6a07ca..f89677fa39 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -1,5 +1,12 @@ { "string": { + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", "TrackerApplication": "Tracker", "Projects": "Your projects", "More": "More", diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index a3e3bacfa6..f202bbba6f 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -1,5 +1,12 @@ { "string": { + "ProjectField": "Поле", + "ProjectFields": "Поля", + "CustomFields": "Пользовательские поля", + "FieldKey": "Ключ", + "FieldType": "Тип", + "FieldDefaultValue": "Значение по умолчанию", + "FieldOptions": "Варианты", "TrackerApplication": "Трекер", "Projects": "Проекты", "More": "Больше", From 5c46c70cd20b652ac9d4aa237cc29f59c8e783bc Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:29:34 +0530 Subject: [PATCH 14/32] feat(tracker): enforce field limit and clean values on field removal Co-Authored-By: Claude Sonnet 5.5 --- models/server-tracker/src/index.ts | 16 +++++ .../__tests__/project-field-trigger.test.ts | 71 +++++++++++++++++++ server-plugins/tracker-resources/src/index.ts | 52 +++++++++++++- server-plugins/tracker/src/index.ts | 2 + 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 server-plugins/tracker-resources/src/__tests__/project-field-trigger.test.ts diff --git a/models/server-tracker/src/index.ts b/models/server-tracker/src/index.ts index 0c508d4243..8404e7a848 100644 --- a/models/server-tracker/src/index.ts +++ b/models/server-tracker/src/index.ts @@ -73,6 +73,22 @@ export function createModel (builder: Builder): void { } }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTracker.trigger.OnProjectFieldCreate, + txMatch: { + _class: core.class.TxCreateDoc, + objectClass: tracker.class.ProjectField + } + }) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTracker.trigger.OnProjectFieldRemove, + txMatch: { + _class: core.class.TxRemoveDoc, + objectClass: tracker.class.ProjectField + } + }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverTracker.trigger.OnDependencyShiftRequest, txMatch: { diff --git a/server-plugins/tracker-resources/src/__tests__/project-field-trigger.test.ts b/server-plugins/tracker-resources/src/__tests__/project-field-trigger.test.ts new file mode 100644 index 0000000000..82adfa04b2 --- /dev/null +++ b/server-plugins/tracker-resources/src/__tests__/project-field-trigger.test.ts @@ -0,0 +1,71 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import core, { type Ref, type Space, type Tx } from '@hcengineering/core' +import tracker, { MAX_PROJECT_FIELDS } from '@hcengineering/tracker' + +import { OnProjectFieldCreate, OnProjectFieldRemove } from '../index' + +const SPACE = 'project-1' as Ref + +function makeControl (docs: Record, removed: Map = new Map()): any { + return { + ctx: {}, + removedMap: removed, + findAll: async (_ctx: unknown, cls: string) => docs[cls] ?? [], + txFactory: { + createTxRemoveDoc: (_class: string, space: string, id: string) => ({ kind: 'remove', _class, space, id }), + createTxUpdateDoc: (_class: string, space: string, id: string, update: any) => ({ + kind: 'update', + _class, + space, + id, + update + }) + } + } +} + +describe('OnProjectFieldCreate', () => { + const createTx = { _class: core.class.TxCreateDoc, objectSpace: SPACE } as unknown as Tx + + it('keeps everything at the limit', async () => { + const fields = Array.from({ length: MAX_PROJECT_FIELDS }, (_, i) => ({ _id: `f${i}`, createdOn: i, space: SPACE })) + const res = await OnProjectFieldCreate([createTx], makeControl({ [tracker.class.ProjectField]: fields })) + expect(res).toEqual([]) + }) + + it('removes the newest fields beyond the limit', async () => { + const fields = Array.from({ length: MAX_PROJECT_FIELDS + 2 }, (_, i) => ({ + _id: `f${i}`, + _class: tracker.class.ProjectField, + createdOn: i, + space: SPACE + })) + const res: any[] = await OnProjectFieldCreate([createTx], makeControl({ [tracker.class.ProjectField]: fields })) + expect(res.map((r) => r.id)).toEqual([`f${MAX_PROJECT_FIELDS}`, `f${MAX_PROJECT_FIELDS + 1}`]) + }) +}) + +describe('OnProjectFieldRemove', () => { + it('strips the value from issues that have it and skips the rest', async () => { + const issues = [ + { _id: 'i1', _class: tracker.class.Issue, space: SPACE, customFields: { size: 3, team: 'a' } }, + { _id: 'i2', _class: tracker.class.Issue, space: SPACE, customFields: { team: 'b' } }, + { _id: 'i3', _class: tracker.class.Issue, space: SPACE } + ] + const removed = new Map([['field-1', { _id: 'field-1', key: 'size', space: SPACE }]]) + const tx = { objectId: 'field-1' } as unknown as Tx + const res: any[] = await OnProjectFieldRemove([tx], makeControl({ [tracker.class.Issue]: issues }, removed)) + expect(res).toHaveLength(1) + expect(res[0].id).toBe('i1') + expect(res[0].update).toEqual({ customFields: { team: 'a' } }) + }) + + it('does nothing when the removed doc is unknown', async () => { + const res = await OnProjectFieldRemove([{ objectId: 'x' } as unknown as Tx], makeControl({})) + expect(res).toEqual([]) + }) +}) diff --git a/server-plugins/tracker-resources/src/index.ts b/server-plugins/tracker-resources/src/index.ts index ab3e82cf52..08262f9bad 100644 --- a/server-plugins/tracker-resources/src/index.ts +++ b/server-plugins/tracker-resources/src/index.ts @@ -45,6 +45,8 @@ import tracker, { groupShiftsByRecipient, Issue, IssueParentInfo, + overLimitFields, + type ProjectField, type ShiftedIssuePayload, TimeSpendReport, trackerId, @@ -166,7 +168,13 @@ export async function OnProjectRemove (txes: Tx[], control: TriggerControl): Pro const result: Tx[] = [] for (const tx of txes) { const ctx = tx as TxRemoveDoc - const classes = [tracker.class.Issue, tracker.class.Component, tracker.class.Milestone, tracker.class.IssueTemplate] + const classes = [ + tracker.class.Issue, + tracker.class.Component, + tracker.class.Milestone, + tracker.class.IssueTemplate, + tracker.class.ProjectField + ] for (const cls of classes) { const docs = await control.findAll(control.ctx, cls, { space: ctx.objectId }) for (const doc of docs) { @@ -183,6 +191,46 @@ export async function OnProjectRemove (txes: Tx[], control: TriggerControl): Pro return result } +/** + * Server-side guard for the per-project custom field limit. A client that skips the UI check + * still cannot exceed GitHub's cap: the newest field beyond the limit is removed again. + * @public + */ +export async function OnProjectFieldCreate (txes: Tx[], control: TriggerControl): Promise { + const result: Tx[] = [] + const handled = new Set>() + for (const tx of txes) { + const createTx = tx as TxCreateDoc + if (handled.has(createTx.objectSpace)) continue + handled.add(createTx.objectSpace) + const fields = await control.findAll(control.ctx, tracker.class.ProjectField, { space: createTx.objectSpace as Ref }) + for (const field of overLimitFields(fields)) { + result.push(control.txFactory.createTxRemoveDoc(field._class, field.space, field._id)) + } + } + return result +} + +/** + * Drop the values of a removed custom field from every issue of the project. + * @public + */ +export async function OnProjectFieldRemove (txes: Tx[], control: TriggerControl): Promise { + const result: Tx[] = [] + for (const tx of txes) { + const rmTx = tx as TxRemoveDoc + const field = control.removedMap.get(rmTx.objectId) as ProjectField | undefined + if (field === undefined) continue + const issues = await control.findAll(control.ctx, tracker.class.Issue, { space: field.space as Ref }) + for (const issue of issues) { + if (issue.customFields === undefined || !(field.key in issue.customFields)) continue + const { [field.key]: _removed, ...rest } = issue.customFields + result.push(control.txFactory.createTxUpdateDoc(issue._class, issue.space, issue._id, { customFields: rest })) + } + } + return result +} + /** * @public */ @@ -873,6 +921,8 @@ export default async () => ({ OnIssueUpdate, OnComponentRemove, OnProjectRemove, + OnProjectFieldCreate, + OnProjectFieldRemove, OnDependencyShiftRequest } }) diff --git a/server-plugins/tracker/src/index.ts b/server-plugins/tracker/src/index.ts index 4bb2ce8a14..17a482a928 100644 --- a/server-plugins/tracker/src/index.ts +++ b/server-plugins/tracker/src/index.ts @@ -38,6 +38,8 @@ export default plugin(serverTrackerId, { OnIssueUpdate: '' as Resource, OnComponentRemove: '' as Resource, OnProjectRemove: '' as Resource, + OnProjectFieldCreate: '' as Resource, + OnProjectFieldRemove: '' as Resource, OnDependencyShiftRequest: '' as Resource } }) From 4fd4aa8340e11a803dec0fdbc848192d713a56a3 Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:34:54 +0530 Subject: [PATCH 15/32] feat(tracker): custom field management UI, editors and issue panel section Co-Authored-By: Claude Sonnet 5.5 --- plugins/tracker-assets/lang/cs.json | 32 ++ plugins/tracker-assets/lang/de.json | 32 ++ plugins/tracker-assets/lang/en.json | 25 ++ plugins/tracker-assets/lang/es.json | 32 ++ plugins/tracker-assets/lang/fr.json | 32 ++ plugins/tracker-assets/lang/it.json | 32 ++ plugins/tracker-assets/lang/ja.json | 32 ++ plugins/tracker-assets/lang/ko.json | 32 ++ plugins/tracker-assets/lang/pl.json | 32 ++ plugins/tracker-assets/lang/pt-br.json | 32 ++ plugins/tracker-assets/lang/pt.json | 32 ++ plugins/tracker-assets/lang/ru.json | 25 ++ plugins/tracker-assets/lang/tr.json | 32 ++ plugins/tracker-assets/lang/zh.json | 32 ++ .../issues/edit/ControlPanel.svelte | 3 + .../components/projects/CreateProject.svelte | 16 + plugins/tracker-resources/src/index.ts | 6 + plugins/tracker-resources/src/plugin.ts | 32 ++ .../projectFields/CustomFieldPresenter.svelte | 26 ++ .../src/projectFields/DateFieldEditor.svelte | 29 ++ .../src/projectFields/FieldValueEditor.svelte | 49 +++ .../projectFields/IssueCustomFields.svelte | 50 +++ .../MultiSelectFieldEditor.svelte | 37 ++ .../projectFields/NumberFieldEditor.svelte | 31 ++ .../projectFields/ProjectFieldsPopup.svelte | 399 ++++++++++++++++++ .../SingleSelectFieldEditor.svelte | 39 ++ .../src/projectFields/TextFieldEditor.svelte | 28 ++ .../projectFields/__tests__/registry.test.ts | 50 +++ .../src/projectFields/projectFieldsStore.ts | 28 ++ .../src/projectFields/registry.ts | 91 ++++ 30 files changed, 1348 insertions(+) create mode 100644 plugins/tracker-resources/src/projectFields/CustomFieldPresenter.svelte create mode 100644 plugins/tracker-resources/src/projectFields/DateFieldEditor.svelte create mode 100644 plugins/tracker-resources/src/projectFields/FieldValueEditor.svelte create mode 100644 plugins/tracker-resources/src/projectFields/IssueCustomFields.svelte create mode 100644 plugins/tracker-resources/src/projectFields/MultiSelectFieldEditor.svelte create mode 100644 plugins/tracker-resources/src/projectFields/NumberFieldEditor.svelte create mode 100644 plugins/tracker-resources/src/projectFields/ProjectFieldsPopup.svelte create mode 100644 plugins/tracker-resources/src/projectFields/SingleSelectFieldEditor.svelte create mode 100644 plugins/tracker-resources/src/projectFields/TextFieldEditor.svelte create mode 100644 plugins/tracker-resources/src/projectFields/__tests__/registry.test.ts create mode 100644 plugins/tracker-resources/src/projectFields/projectFieldsStore.ts create mode 100644 plugins/tracker-resources/src/projectFields/registry.ts diff --git a/plugins/tracker-assets/lang/cs.json b/plugins/tracker-assets/lang/cs.json index 4d785162d8..ce78b06730 100644 --- a/plugins/tracker-assets/lang/cs.json +++ b/plugins/tracker-assets/lang/cs.json @@ -1,5 +1,37 @@ { "string": { + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Přehled", "Projects": "Vaše projekty", "More": "Více", diff --git a/plugins/tracker-assets/lang/de.json b/plugins/tracker-assets/lang/de.json index 4886ccce28..f0f32b0ab5 100644 --- a/plugins/tracker-assets/lang/de.json +++ b/plugins/tracker-assets/lang/de.json @@ -1,5 +1,37 @@ { "string": { + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Tracker", "Projects": "Deine Projekte", "More": "Mehr", diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index f89677fa39..dfb93a75ec 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -7,6 +7,31 @@ "FieldType": "Type", "FieldDefaultValue": "Default value", "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Tracker", "Projects": "Your projects", "More": "More", diff --git a/plugins/tracker-assets/lang/es.json b/plugins/tracker-assets/lang/es.json index 1ff5a8a77b..527e4635e6 100644 --- a/plugins/tracker-assets/lang/es.json +++ b/plugins/tracker-assets/lang/es.json @@ -1,5 +1,37 @@ { "string": { + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Seguimiento", "Projects": "Tus proyectos", "More": "Más", diff --git a/plugins/tracker-assets/lang/fr.json b/plugins/tracker-assets/lang/fr.json index df9e6094c2..68018bd9eb 100644 --- a/plugins/tracker-assets/lang/fr.json +++ b/plugins/tracker-assets/lang/fr.json @@ -1,5 +1,37 @@ { "string": { + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Suivi", "Projects": "Vos projets", "More": "Plus", diff --git a/plugins/tracker-assets/lang/it.json b/plugins/tracker-assets/lang/it.json index 318f9326f7..947dbab1f3 100644 --- a/plugins/tracker-assets/lang/it.json +++ b/plugins/tracker-assets/lang/it.json @@ -1,5 +1,37 @@ { "string": { + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Tracker", "Projects": "I tuoi progetti", "More": "Altro", diff --git a/plugins/tracker-assets/lang/ja.json b/plugins/tracker-assets/lang/ja.json index d060f6f83e..db5840f5b6 100644 --- a/plugins/tracker-assets/lang/ja.json +++ b/plugins/tracker-assets/lang/ja.json @@ -1,5 +1,37 @@ { "string": { + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "トラッカー", "Projects": "あなたのプロジェクト", "More": "その他", diff --git a/plugins/tracker-assets/lang/ko.json b/plugins/tracker-assets/lang/ko.json index 0d3bba943e..32623cb515 100644 --- a/plugins/tracker-assets/lang/ko.json +++ b/plugins/tracker-assets/lang/ko.json @@ -1,5 +1,37 @@ { "string": { + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "트래커", "Projects": "내 프로젝트", "More": "더 보기", diff --git a/plugins/tracker-assets/lang/pl.json b/plugins/tracker-assets/lang/pl.json index fa2e855527..65eac4eb90 100644 --- a/plugins/tracker-assets/lang/pl.json +++ b/plugins/tracker-assets/lang/pl.json @@ -1,5 +1,37 @@ { "string": { + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Zarządzanie zadaniami", "Projects": "Twoje projekty", "More": "Więcej", diff --git a/plugins/tracker-assets/lang/pt-br.json b/plugins/tracker-assets/lang/pt-br.json index fb3ea90226..ff2fabcdfa 100644 --- a/plugins/tracker-assets/lang/pt-br.json +++ b/plugins/tracker-assets/lang/pt-br.json @@ -1,5 +1,37 @@ { "string": { + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Seguimento", "Projects": "Seus projetos", "More": "Mais", diff --git a/plugins/tracker-assets/lang/pt.json b/plugins/tracker-assets/lang/pt.json index 4e5ffcb65a..ab33ebeb15 100644 --- a/plugins/tracker-assets/lang/pt.json +++ b/plugins/tracker-assets/lang/pt.json @@ -1,5 +1,37 @@ { "string": { + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Seguimento", "Projects": "Os teus projetos", "More": "Mais", diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index f202bbba6f..6d8f097a22 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -7,6 +7,31 @@ "FieldType": "Тип", "FieldDefaultValue": "Значение по умолчанию", "FieldOptions": "Варианты", + "FieldEmptyValue": "Пусто", + "NewProjectField": "Новое поле", + "EditProjectField": "Редактировать поле", + "DeleteProjectField": "Удалить поле {name}?", + "DeleteProjectFieldConfirm": "Поле и его значения во всех задачах будут удалены.", + "NoProjectFields": "Полей пока нет", + "ProjectFieldNamePlaceholder": "Название поля", + "ProjectFieldDescriptionPlaceholder": "Описание", + "ProjectFieldTypeText": "Текст", + "ProjectFieldTypeNumber": "Число", + "ProjectFieldTypeDate": "Дата", + "ProjectFieldTypeSingleSelect": "Одиночный выбор", + "ProjectFieldTypeMultiSelect": "Множественный выбор", + "AddProjectFieldOption": "Добавить вариант", + "ProjectFieldOptionLabel": "Вариант", + "ProjectFieldOptionDescription": "Описание", + "ProjectFieldErrorEmptyLabel": "Укажите название поля", + "ProjectFieldErrorDuplicateLabel": "Поле с таким названием уже существует", + "ProjectFieldErrorTooManyFields": "В проекте может быть не более 50 полей", + "ProjectFieldErrorTooManyOptions": "В поле может быть не более 50 вариантов", + "ProjectFieldErrorDuplicateOption": "Названия вариантов должны быть уникальными", + "ProjectFieldErrorEmptyOption": "Название варианта не может быть пустым", + "ProjectFieldErrorOptionsRequired": "Добавьте хотя бы один вариант", + "ProjectFieldErrorDefaultNotAllowed": "Этот тип поля не поддерживает значение по умолчанию", + "ProjectFieldErrorInvalidDefault": "Недопустимое значение по умолчанию", "TrackerApplication": "Трекер", "Projects": "Проекты", "More": "Больше", diff --git a/plugins/tracker-assets/lang/tr.json b/plugins/tracker-assets/lang/tr.json index 34aa6ad5d5..1745950dd2 100644 --- a/plugins/tracker-assets/lang/tr.json +++ b/plugins/tracker-assets/lang/tr.json @@ -1,5 +1,37 @@ { "string": { + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Tracker", "Projects": "Projeleriniz", "More": "Daha fazla", diff --git a/plugins/tracker-assets/lang/zh.json b/plugins/tracker-assets/lang/zh.json index 09b80894b2..6e04f8666e 100644 --- a/plugins/tracker-assets/lang/zh.json +++ b/plugins/tracker-assets/lang/zh.json @@ -1,5 +1,37 @@ { "string": { + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "追踪器", "Projects": "您的项目", "More": "更多", diff --git a/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte b/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte index 8bbfad89d2..f435ab4a43 100644 --- a/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte +++ b/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte @@ -40,6 +40,7 @@ import StartDateEditor from '../StartDateEditor.svelte' import StatusEditor from '../StatusEditor.svelte' import SchedulingModeEditor from '../SchedulingModeEditor.svelte' + import IssueCustomFields from '../../../projectFields/IssueCustomFields.svelte' import notification from '@hcengineering/notification' export let issue: Issue @@ -233,6 +234,8 @@ + + {#if keys.length > 0}
{#each keys as key (typeof key === 'string' ? key : key.key)} diff --git a/plugins/tracker-resources/src/components/projects/CreateProject.svelte b/plugins/tracker-resources/src/components/projects/CreateProject.svelte index 3fc689fd3c..a3100e21b9 100644 --- a/plugins/tracker-resources/src/components/projects/CreateProject.svelte +++ b/plugins/tracker-resources/src/components/projects/CreateProject.svelte @@ -58,6 +58,8 @@ import { deepEqual } from 'fast-equals' import { createEventDispatcher } from 'svelte' + import ProjectFieldsPopup from '../../projectFields/ProjectFieldsPopup.svelte' + import tracker from '../../plugin' import StatusSelector from '../issues/StatusSelector.svelte' import { workingDaysUpdate } from '../gantt/lib/working-days-editor' @@ -614,6 +616,20 @@ />
{/each} + + {#if project != null} +
+
+
+
+ {/if} diff --git a/plugins/tracker-resources/src/index.ts b/plugins/tracker-resources/src/index.ts index 0dc6d9d682..ccde690ce4 100644 --- a/plugins/tracker-resources/src/index.ts +++ b/plugins/tracker-resources/src/index.ts @@ -188,6 +188,12 @@ export { default as SubIssueList } from './components/issues/edit/SubIssueList.s export { default as IssueStatusIcon } from './components/issues/IssueStatusIcon.svelte' export { default as StatusPresenter } from './components/issues/StatusPresenter.svelte' +export { default as CustomFieldPresenter } from './projectFields/CustomFieldPresenter.svelte' +export { default as FieldValueEditor } from './projectFields/FieldValueEditor.svelte' +export { default as ProjectFieldsPopup } from './projectFields/ProjectFieldsPopup.svelte' +export * from './projectFields/registry' +export { projectFieldsStore } from './projectFields/projectFieldsStore' + export { activeProjects, CreateProject, IssuePresenter, PriorityEditor, StatusEditor, TitlePresenter } export async function queryIssue ( diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index 665a87f98e..c41cb28dbb 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -45,6 +45,38 @@ export default mergeIds(trackerId, tracker, { IssueCategory: '' as Ref }, string: { + ProjectField: '' as IntlString, + ProjectFields: '' as IntlString, + CustomFields: '' as IntlString, + FieldKey: '' as IntlString, + FieldType: '' as IntlString, + FieldDefaultValue: '' as IntlString, + FieldOptions: '' as IntlString, + FieldEmptyValue: '' as IntlString, + NewProjectField: '' as IntlString, + EditProjectField: '' as IntlString, + DeleteProjectField: '' as IntlString, + DeleteProjectFieldConfirm: '' as IntlString, + NoProjectFields: '' as IntlString, + ProjectFieldNamePlaceholder: '' as IntlString, + ProjectFieldDescriptionPlaceholder: '' as IntlString, + ProjectFieldTypeText: '' as IntlString, + ProjectFieldTypeNumber: '' as IntlString, + ProjectFieldTypeDate: '' as IntlString, + ProjectFieldTypeSingleSelect: '' as IntlString, + ProjectFieldTypeMultiSelect: '' as IntlString, + AddProjectFieldOption: '' as IntlString, + ProjectFieldOptionLabel: '' as IntlString, + ProjectFieldOptionDescription: '' as IntlString, + ProjectFieldErrorEmptyLabel: '' as IntlString, + ProjectFieldErrorDuplicateLabel: '' as IntlString, + ProjectFieldErrorTooManyFields: '' as IntlString, + ProjectFieldErrorTooManyOptions: '' as IntlString, + ProjectFieldErrorDuplicateOption: '' as IntlString, + ProjectFieldErrorEmptyOption: '' as IntlString, + ProjectFieldErrorOptionsRequired: '' as IntlString, + ProjectFieldErrorDefaultNotAllowed: '' as IntlString, + ProjectFieldErrorInvalidDefault: '' as IntlString, More: '' as IntlString, Delete: '' as IntlString, Open: '' as IntlString, diff --git a/plugins/tracker-resources/src/projectFields/CustomFieldPresenter.svelte b/plugins/tracker-resources/src/projectFields/CustomFieldPresenter.svelte new file mode 100644 index 0000000000..7f1f50d90c --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/CustomFieldPresenter.svelte @@ -0,0 +1,26 @@ + + + +{#if value === null} + — +{:else if field.type === ProjectFieldType.Date && typeof value === 'number'} + +{:else if field.type === ProjectFieldType.SingleSelect || field.type === ProjectFieldType.MultiSelect} + {labels.join(', ')} +{:else} + {value} +{/if} diff --git a/plugins/tracker-resources/src/projectFields/DateFieldEditor.svelte b/plugins/tracker-resources/src/projectFields/DateFieldEditor.svelte new file mode 100644 index 0000000000..2bae039a46 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/DateFieldEditor.svelte @@ -0,0 +1,29 @@ + + + + diff --git a/plugins/tracker-resources/src/projectFields/FieldValueEditor.svelte b/plugins/tracker-resources/src/projectFields/FieldValueEditor.svelte new file mode 100644 index 0000000000..feca3f387b --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/FieldValueEditor.svelte @@ -0,0 +1,49 @@ + + + +{#if field.type === ProjectFieldType.Text} + +{:else if field.type === ProjectFieldType.Number} + +{:else if field.type === ProjectFieldType.Date} + +{:else if field.type === ProjectFieldType.SingleSelect} + +{:else if field.type === ProjectFieldType.MultiSelect} + +{/if} diff --git a/plugins/tracker-resources/src/projectFields/IssueCustomFields.svelte b/plugins/tracker-resources/src/projectFields/IssueCustomFields.svelte new file mode 100644 index 0000000000..a241261ae9 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/IssueCustomFields.svelte @@ -0,0 +1,50 @@ + + + +{#if fields.length > 0} +
+ {#each fields as field (field._id)} + + {field.label} + + setValue(field.key, e.detail)} + /> + {/each} +{/if} diff --git a/plugins/tracker-resources/src/projectFields/MultiSelectFieldEditor.svelte b/plugins/tracker-resources/src/projectFields/MultiSelectFieldEditor.svelte new file mode 100644 index 0000000000..5129c9f02a --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/MultiSelectFieldEditor.svelte @@ -0,0 +1,37 @@ + + + + diff --git a/plugins/tracker-resources/src/projectFields/NumberFieldEditor.svelte b/plugins/tracker-resources/src/projectFields/NumberFieldEditor.svelte new file mode 100644 index 0000000000..494489f28d --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/NumberFieldEditor.svelte @@ -0,0 +1,31 @@ + + + + diff --git a/plugins/tracker-resources/src/projectFields/ProjectFieldsPopup.svelte b/plugins/tracker-resources/src/projectFields/ProjectFieldsPopup.svelte new file mode 100644 index 0000000000..c9880ba0d4 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/ProjectFieldsPopup.svelte @@ -0,0 +1,399 @@ + + + + dispatch('close')} + on:close + on:changeContent +> + {#if draft === undefined} + {#if fields.length === 0} +
+
+ {/if} + {#each fields as field, i (field._id)} +
+ +
{field.label}
+ + move(field, -1)} /> + move(field, 1)} + /> +
+ {/each} +
+
+ {:else} + + +
+
+ + {#if isSelect(draft.type)} +
+ {#each draft.options as option (option.value)} +
+
+ {/each} +
+
+ {/if} + + {#if supportsDefault(draft.type)} +
+
+ {/if} + + {#if showErrors && error !== undefined} +
+ {/if} + {/if} +
+ + diff --git a/plugins/tracker-resources/src/projectFields/SingleSelectFieldEditor.svelte b/plugins/tracker-resources/src/projectFields/SingleSelectFieldEditor.svelte new file mode 100644 index 0000000000..7534de5e67 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/SingleSelectFieldEditor.svelte @@ -0,0 +1,39 @@ + + + + diff --git a/plugins/tracker-resources/src/projectFields/TextFieldEditor.svelte b/plugins/tracker-resources/src/projectFields/TextFieldEditor.svelte new file mode 100644 index 0000000000..54616d51ac --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/TextFieldEditor.svelte @@ -0,0 +1,28 @@ + + + + diff --git a/plugins/tracker-resources/src/projectFields/__tests__/registry.test.ts b/plugins/tracker-resources/src/projectFields/__tests__/registry.test.ts new file mode 100644 index 0000000000..d22f4dc478 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/__tests__/registry.test.ts @@ -0,0 +1,50 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Ref } from '@hcengineering/core' +import { ProjectFieldType, type ProjectField } from '@hcengineering/tracker' +import { buildRegistry, computeMove, mergeCustomFieldValue, nextFieldPosition, readIssueValue, resolveField } from '../registry' + +function f (id: string, key: string, position: number, type = ProjectFieldType.Text): ProjectField { + return { _id: id, key, position, type, label: key } as unknown as ProjectField +} + +describe('project field registry', () => { + const fields = [f('b', 'beta', 1), f('a', 'alpha', 0), f('c', 'points', 2, ProjectFieldType.Number)] + + it('sorts and resolves by key', () => { + const reg = buildRegistry(fields) + expect(reg.fields.map((x) => x.key)).toEqual(['alpha', 'beta', 'points']) + expect(resolveField(reg, 'beta')?._id).toBe('b') + expect(resolveField(reg, 'zzz')).toBeUndefined() + }) + + it('computes next position', () => { + expect(nextFieldPosition([])).toBe(0) + expect(nextFieldPosition(fields)).toBe(3) + }) + + it('moves fields and ignores out-of-range moves', () => { + expect(computeMove(fields, 'a' as Ref, -1)).toEqual([]) + expect(computeMove(fields, 'c' as Ref, 1)).toEqual([]) + expect(computeMove(fields, 'a' as Ref, 1)).toEqual([ + { id: 'b', position: 0 }, + { id: 'a', position: 1 } + ]) + }) + + it('merges values and removes empty ones', () => { + expect(mergeCustomFieldValue({ x: 1 }, 'y', 'v')).toEqual({ x: 1, y: 'v' }) + expect(mergeCustomFieldValue({ x: 1, y: 'v' }, 'y', null)).toEqual({ x: 1 }) + expect(mergeCustomFieldValue(undefined, 'y', [])).toEqual({}) + }) + + it('reads normalized issue values', () => { + const reg = buildRegistry(fields) + expect(readIssueValue(reg, { points: 5 }, 'points')).toBe(5) + expect(readIssueValue(reg, { points: 'x' }, 'points')).toBeNull() + expect(readIssueValue(reg, {}, 'missing')).toBeNull() + }) +}) diff --git a/plugins/tracker-resources/src/projectFields/projectFieldsStore.ts b/plugins/tracker-resources/src/projectFields/projectFieldsStore.ts new file mode 100644 index 0000000000..6650c0ba05 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/projectFieldsStore.ts @@ -0,0 +1,28 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { createQuery } from '@hcengineering/presentation' +import type { Project } from '@hcengineering/tracker' +import type { Ref } from '@hcengineering/core' +import { readable, type Readable } from 'svelte/store' + +import tracker from '../plugin' +import { buildRegistry, type ProjectFieldRegistry } from './registry' + +/** + * Live registry of the fields defined in a project. The query is started on first + * subscription and stopped when the last subscriber leaves. + */ +export function projectFieldsStore (project: Ref): Readable { + return readable(buildRegistry([]), (set) => { + const query = createQuery(true) + query.query(tracker.class.ProjectField, { space: project }, (result) => { + set(buildRegistry(result)) + }) + return () => { + query.unsubscribe() + } + }) +} diff --git a/plugins/tracker-resources/src/projectFields/registry.ts b/plugins/tracker-resources/src/projectFields/registry.ts new file mode 100644 index 0000000000..11dc858e90 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/registry.ts @@ -0,0 +1,91 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { ProjectField, ProjectFieldValue } from '@hcengineering/tracker' +import { getFieldValue } from '@hcengineering/tracker' + +/** + * Immutable lookup of a project's field definitions. + */ +export interface ProjectFieldRegistry { + // Fields ordered by position + fields: ProjectField[] + byKey: Map +} + +/** + * Order fields by position, falling back to creation time for equal positions. + */ +export function sortFields (fields: readonly ProjectField[]): ProjectField[] { + return [...fields].sort((a, b) => a.position - b.position || (a.createdOn ?? 0) - (b.createdOn ?? 0)) +} + +export function buildRegistry (fields: readonly ProjectField[]): ProjectFieldRegistry { + const sorted = sortFields(fields) + return { fields: sorted, byKey: new Map(sorted.map((f) => [f.key, f])) } +} + +/** + * Resolve a key stored in issue.customFields to its definition. + */ +export function resolveField (registry: ProjectFieldRegistry, key: string): ProjectField | undefined { + return registry.byKey.get(key) +} + +/** + * Position for a field appended after all existing ones. + */ +export function nextFieldPosition (fields: readonly ProjectField[]): number { + return fields.reduce((max, f) => Math.max(max, f.position), -1) + 1 +} + +/** + * Compute position updates that move a field one step up (-1) or down (1). + * Positions are renumbered densely so duplicates never persist. + * Returns an empty list when the move is not possible. + */ +export function computeMove ( + fields: readonly ProjectField[], + id: ProjectField['_id'], + direction: -1 | 1 +): Array<{ id: ProjectField['_id'], position: number }> { + const sorted = sortFields(fields) + const from = sorted.findIndex((f) => f._id === id) + const to = from + direction + if (from < 0 || to < 0 || to >= sorted.length) return [] + const order = [...sorted] + ;[order[from], order[to]] = [order[to], order[from]] + return order.map((f, position) => ({ id: f._id, position })).filter((u) => sorted.find((f) => f._id === u.id)?.position !== u.position) +} + +/** + * Merge a new value into a customFields record. Empty values remove the key. + */ +export function mergeCustomFieldValue ( + customFields: Record | undefined, + key: string, + value: ProjectFieldValue | undefined +): Record { + const next: Record = { ...(customFields ?? {}) } + if (value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete next[key] + } else { + next[key] = value + } + return next +} + +/** + * Read an issue's value for a field through the registry. + */ +export function readIssueValue ( + registry: ProjectFieldRegistry, + customFields: Record | undefined, + key: string +): ProjectFieldValue { + const field = resolveField(registry, key) + return field === undefined ? null : getFieldValue(customFields, field) +} From df9e07d9d83f3d60f2697a2a4ead06d048cf488b Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:48:36 +0530 Subject: [PATCH 16/32] feat(tracker): custom fields as columns, filter, sort and group-by Co-Authored-By: Claude Sonnet 5.5 --- models/tracker/src/plugin.ts | 7 - plugins/tracker-assets/lang/cs.json | 23 ++ plugins/tracker-assets/lang/de.json | 23 ++ plugins/tracker-assets/lang/en.json | 23 ++ plugins/tracker-assets/lang/es.json | 23 ++ plugins/tracker-assets/lang/fr.json | 23 ++ plugins/tracker-assets/lang/it.json | 23 ++ plugins/tracker-assets/lang/ja.json | 23 ++ plugins/tracker-assets/lang/ko.json | 23 ++ plugins/tracker-assets/lang/pl.json | 23 ++ plugins/tracker-assets/lang/pt-br.json | 23 ++ plugins/tracker-assets/lang/pt.json | 23 ++ plugins/tracker-assets/lang/ru.json | 23 ++ plugins/tracker-assets/lang/tr.json | 23 ++ plugins/tracker-assets/lang/zh.json | 23 ++ .../src/components/issues/IssuesView.svelte | 128 ++++++- plugins/tracker-resources/src/index.ts | 4 +- plugins/tracker-resources/src/plugin.ts | 31 +- .../projectFields/CustomFieldColumn.svelte | 107 ++++++ .../CustomFieldFilterButton.svelte | 32 ++ .../CustomFieldFilterPopup.svelte | 281 +++++++++++++++ .../CustomFieldGroupHeader.svelte | 15 + .../projectFields/IssueCustomFields.svelte | 13 +- .../src/projectFields/__tests__/query.test.ts | 248 +++++++++++++ .../src/projectFields/actions.ts | 30 ++ .../src/projectFields/customFieldView.ts | 145 ++++++++ .../src/projectFields/projectFieldsStore.ts | 15 + .../src/projectFields/query.ts | 326 ++++++++++++++++++ .../view-resources/src/clientViewExtension.ts | 76 ++++ .../src/components/ViewOptions.svelte | 23 +- .../src/components/ViewOptionsButton.svelte | 9 + .../src/components/ViewletSetting.svelte | 20 ++ .../src/components/list/List.svelte | 12 +- .../src/components/list/ListCategories.svelte | 48 ++- .../src/components/list/ListCategory.svelte | 14 +- .../src/components/list/ListHeader.svelte | 7 +- plugins/view-resources/src/index.ts | 1 + 37 files changed, 1871 insertions(+), 43 deletions(-) create mode 100644 plugins/tracker-resources/src/projectFields/CustomFieldColumn.svelte create mode 100644 plugins/tracker-resources/src/projectFields/CustomFieldFilterButton.svelte create mode 100644 plugins/tracker-resources/src/projectFields/CustomFieldFilterPopup.svelte create mode 100644 plugins/tracker-resources/src/projectFields/CustomFieldGroupHeader.svelte create mode 100644 plugins/tracker-resources/src/projectFields/__tests__/query.test.ts create mode 100644 plugins/tracker-resources/src/projectFields/actions.ts create mode 100644 plugins/tracker-resources/src/projectFields/customFieldView.ts create mode 100644 plugins/tracker-resources/src/projectFields/query.ts create mode 100644 plugins/view-resources/src/clientViewExtension.ts diff --git a/models/tracker/src/plugin.ts b/models/tracker/src/plugin.ts index 61e176a657..89d904ac03 100644 --- a/models/tracker/src/plugin.ts +++ b/models/tracker/src/plugin.ts @@ -27,13 +27,6 @@ import { type Application } from '@hcengineering/workbench' export default mergeIds(trackerId, tracker, { string: { - ProjectField: '' as IntlString, - ProjectFields: '' as IntlString, - CustomFields: '' as IntlString, - FieldKey: '' as IntlString, - FieldType: '' as IntlString, - FieldDefaultValue: '' as IntlString, - FieldOptions: '' as IntlString, Projects: '' as IntlString, GotoIssues: '' as IntlString, GotoActive: '' as IntlString, diff --git a/plugins/tracker-assets/lang/cs.json b/plugins/tracker-assets/lang/cs.json index ce78b06730..5c6f34bb72 100644 --- a/plugins/tracker-assets/lang/cs.json +++ b/plugins/tracker-assets/lang/cs.json @@ -1,5 +1,28 @@ { "string": { + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", "ProjectField": "Field", "ProjectFields": "Fields", "CustomFields": "Custom fields", diff --git a/plugins/tracker-assets/lang/de.json b/plugins/tracker-assets/lang/de.json index f0f32b0ab5..36919ab268 100644 --- a/plugins/tracker-assets/lang/de.json +++ b/plugins/tracker-assets/lang/de.json @@ -1,5 +1,28 @@ { "string": { + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", "ProjectField": "Field", "ProjectFields": "Fields", "CustomFields": "Custom fields", diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index dfb93a75ec..63caeb4260 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -1,5 +1,28 @@ { "string": { + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", "ProjectField": "Field", "ProjectFields": "Fields", "CustomFields": "Custom fields", diff --git a/plugins/tracker-assets/lang/es.json b/plugins/tracker-assets/lang/es.json index 527e4635e6..acbe0dfc88 100644 --- a/plugins/tracker-assets/lang/es.json +++ b/plugins/tracker-assets/lang/es.json @@ -1,5 +1,28 @@ { "string": { + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", "ProjectField": "Field", "ProjectFields": "Fields", "CustomFields": "Custom fields", diff --git a/plugins/tracker-assets/lang/fr.json b/plugins/tracker-assets/lang/fr.json index 68018bd9eb..0383d21e42 100644 --- a/plugins/tracker-assets/lang/fr.json +++ b/plugins/tracker-assets/lang/fr.json @@ -1,5 +1,28 @@ { "string": { + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", "ProjectField": "Field", "ProjectFields": "Fields", "CustomFields": "Custom fields", diff --git a/plugins/tracker-assets/lang/it.json b/plugins/tracker-assets/lang/it.json index 947dbab1f3..56d2525f29 100644 --- a/plugins/tracker-assets/lang/it.json +++ b/plugins/tracker-assets/lang/it.json @@ -1,5 +1,28 @@ { "string": { + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", "ProjectField": "Field", "ProjectFields": "Fields", "CustomFields": "Custom fields", diff --git a/plugins/tracker-assets/lang/ja.json b/plugins/tracker-assets/lang/ja.json index db5840f5b6..db427333f0 100644 --- a/plugins/tracker-assets/lang/ja.json +++ b/plugins/tracker-assets/lang/ja.json @@ -1,5 +1,28 @@ { "string": { + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", "ProjectField": "Field", "ProjectFields": "Fields", "CustomFields": "Custom fields", diff --git a/plugins/tracker-assets/lang/ko.json b/plugins/tracker-assets/lang/ko.json index 32623cb515..408df5abef 100644 --- a/plugins/tracker-assets/lang/ko.json +++ b/plugins/tracker-assets/lang/ko.json @@ -1,5 +1,28 @@ { "string": { + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", "ProjectField": "Field", "ProjectFields": "Fields", "CustomFields": "Custom fields", diff --git a/plugins/tracker-assets/lang/pl.json b/plugins/tracker-assets/lang/pl.json index 65eac4eb90..b48eaf4d49 100644 --- a/plugins/tracker-assets/lang/pl.json +++ b/plugins/tracker-assets/lang/pl.json @@ -1,5 +1,28 @@ { "string": { + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", "ProjectField": "Field", "ProjectFields": "Fields", "CustomFields": "Custom fields", diff --git a/plugins/tracker-assets/lang/pt-br.json b/plugins/tracker-assets/lang/pt-br.json index ff2fabcdfa..d1f4a84ca8 100644 --- a/plugins/tracker-assets/lang/pt-br.json +++ b/plugins/tracker-assets/lang/pt-br.json @@ -1,5 +1,28 @@ { "string": { + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", "ProjectField": "Field", "ProjectFields": "Fields", "CustomFields": "Custom fields", diff --git a/plugins/tracker-assets/lang/pt.json b/plugins/tracker-assets/lang/pt.json index ab33ebeb15..51f6831f41 100644 --- a/plugins/tracker-assets/lang/pt.json +++ b/plugins/tracker-assets/lang/pt.json @@ -1,5 +1,28 @@ { "string": { + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", "ProjectField": "Field", "ProjectFields": "Fields", "CustomFields": "Custom fields", diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index 6d8f097a22..4506e22727 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -1,5 +1,28 @@ { "string": { + "CustomFieldFilter": "Фильтр по полям", + "AddFieldFilter": "Добавить фильтр по полю", + "NoFieldFilters": "Нет фильтров по пользовательским полям", + "ClearFieldFilters": "Очистить все", + "RemoveFieldFilter": "Удалить фильтр", + "FieldFilterUnknownField": "Неизвестное поле", + "FieldFilterValue": "Значение", + "FieldFilterFrom": "От", + "FieldFilterTo": "До", + "FieldFilterOpContains": "содержит", + "FieldFilterOpEq": "равно", + "FieldFilterOpGt": "больше", + "FieldFilterOpGte": "больше или равно", + "FieldFilterOpLt": "меньше", + "FieldFilterOpLte": "меньше или равно", + "FieldFilterOpBetween": "между", + "FieldFilterOpBefore": "до", + "FieldFilterOpAfter": "после", + "FieldFilterOpAnyOf": "любое из", + "FieldFilterOpIsEmpty": "пусто", + "FieldFilterOpIsNotEmpty": "не пусто", + "CustomFieldScanLimitExceeded": "Фильтрация, сортировка и группировка по пользовательским полям отключены: в этом представлении больше {limit} задач. Сузьте выборку другими фильтрами.", + "NoFieldValue": "Нет значения: {field}", "ProjectField": "Поле", "ProjectFields": "Поля", "CustomFields": "Пользовательские поля", diff --git a/plugins/tracker-assets/lang/tr.json b/plugins/tracker-assets/lang/tr.json index 1745950dd2..a2b24358ae 100644 --- a/plugins/tracker-assets/lang/tr.json +++ b/plugins/tracker-assets/lang/tr.json @@ -1,5 +1,28 @@ { "string": { + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", "ProjectField": "Field", "ProjectFields": "Fields", "CustomFields": "Custom fields", diff --git a/plugins/tracker-assets/lang/zh.json b/plugins/tracker-assets/lang/zh.json index 6e04f8666e..9c271b7294 100644 --- a/plugins/tracker-assets/lang/zh.json +++ b/plugins/tracker-assets/lang/zh.json @@ -1,5 +1,28 @@ { "string": { + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", "ProjectField": "Field", "ProjectFields": "Fields", "CustomFields": "Custom fields", diff --git a/plugins/tracker-resources/src/components/issues/IssuesView.svelte b/plugins/tracker-resources/src/components/issues/IssuesView.svelte index 71bd34e4b0..d188a36674 100644 --- a/plugins/tracker-resources/src/components/issues/IssuesView.svelte +++ b/plugins/tracker-resources/src/components/issues/IssuesView.svelte @@ -1,12 +1,13 @@ + +{#if field !== undefined && field.type !== ProjectFieldType.Iteration} + + +
+ {#if isText} + {#if editing && !readonly} + + {:else} + + +
{ + if (!readonly) editing = true + }} + > + +
+ {/if} + {:else} + save(e.detail)} /> + {/if} +
+{/if} + + diff --git a/plugins/tracker-resources/src/projectFields/CustomFieldFilterButton.svelte b/plugins/tracker-resources/src/projectFields/CustomFieldFilterButton.svelte new file mode 100644 index 0000000000..ecae0ea0f5 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/CustomFieldFilterButton.svelte @@ -0,0 +1,32 @@ + + + +
+
+
+ + diff --git a/plugins/tracker-resources/src/projectFields/CustomFieldGroupHeader.svelte b/plugins/tracker-resources/src/projectFields/CustomFieldGroupHeader.svelte new file mode 100644 index 0000000000..64f7d7784c --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/CustomFieldGroupHeader.svelte @@ -0,0 +1,15 @@ + + + +{label} diff --git a/plugins/tracker-resources/src/projectFields/IssueCustomFields.svelte b/plugins/tracker-resources/src/projectFields/IssueCustomFields.svelte index a241261ae9..377bc5c7d6 100644 --- a/plugins/tracker-resources/src/projectFields/IssueCustomFields.svelte +++ b/plugins/tracker-resources/src/projectFields/IssueCustomFields.svelte @@ -8,7 +8,7 @@ import { ProjectFieldType } from '@hcengineering/tracker' import FieldValueEditor from './FieldValueEditor.svelte' - import { mergeCustomFieldValue } from './registry' + import { setIssueCustomFieldValue } from './actions' import { projectFieldsStore } from './projectFieldsStore' export let issue: Issue @@ -21,16 +21,7 @@ $: fields = $registry.fields.filter((f) => f.type !== ProjectFieldType.Iteration) async function setValue (key: string, value: ProjectFieldValue): Promise { - const customFields = mergeCustomFieldValue(issue.customFields, key, value) - await client.updateCollection( - issue._class, - issue.space, - issue._id, - issue.attachedTo, - issue.attachedToClass, - issue.collection, - { customFields } - ) + await setIssueCustomFieldValue(client, issue, key, value) } diff --git a/plugins/tracker-resources/src/projectFields/__tests__/query.test.ts b/plugins/tracker-resources/src/projectFields/__tests__/query.test.ts new file mode 100644 index 0000000000..bdf18b7925 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/__tests__/query.test.ts @@ -0,0 +1,248 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { ProjectFieldType, type ProjectField } from '@hcengineering/tracker' +import { + activeFilterCount, + buildFieldComparator, + buildFieldPredicate, + buildFiltersPredicate, + buildGroupCategories, + endOfDay, + startOfDay, + DEFAULT_CUSTOM_FIELD_SCAN_LIMIT, + exceedsScanLimit, + isFilterComplete, + operatorsFor, + parseCustomFieldViewKey, + resolveScanLimit, + toCustomFieldViewKey, + type CustomFieldFilter +} from '../query' + +function field (key: string, type: ProjectFieldType, options?: Array<[string, string]>): ProjectField { + return { + key, + type, + options: options?.map(([value, label]) => ({ value, label })) + } as unknown as ProjectField +} + +const text = field('note', ProjectFieldType.Text) +const num = field('points', ProjectFieldType.Number) +const date = field('due', ProjectFieldType.Date) +const single = field('size', ProjectFieldType.SingleSelect, [ + ['s', 'Small'], + ['m', 'Medium'], + ['l', 'Large'] +]) +const multi = field('tags', ProjectFieldType.MultiSelect, [ + ['a', 'A'], + ['b', 'B'], + ['c', 'C'] +]) + +const day = 24 * 3600 * 1000 + +describe('custom field filter predicates', () => { + it('text contains is case insensitive and ignores non-strings', () => { + const p = buildFieldPredicate(text, { operator: 'contains', value: 'Foo' }) + expect(p({ note: 'a fOo b' })).toBe(true) + expect(p({ note: 'bar' })).toBe(false) + expect(p({})).toBe(false) + expect(p(undefined)).toBe(false) + }) + + it('number comparisons', () => { + const ctx = (op: any, v: number) => buildFieldPredicate(num, { operator: op, value: v }) + expect(ctx('eq', 3)({ points: 3 })).toBe(true) + expect(ctx('eq', 3)({ points: 4 })).toBe(false) + expect(ctx('gt', 3)({ points: 3 })).toBe(false) + expect(ctx('gte', 3)({ points: 3 })).toBe(true) + expect(ctx('lt', 3)({ points: 2 })).toBe(true) + expect(ctx('lte', 3)({ points: 4 })).toBe(false) + // zero is a real value, not empty + expect(ctx('eq', 0)({ points: 0 })).toBe(true) + expect(ctx('lt', 5)({})).toBe(false) + }) + + it('number and date ranges support open ends and are inclusive', () => { + const range = buildFieldPredicate(num, { operator: 'between', value: { from: 2, to: 5 } }) + expect(range({ points: 2 })).toBe(true) + expect(range({ points: 5 })).toBe(true) + expect(range({ points: 6 })).toBe(false) + const open = buildFieldPredicate(date, { operator: 'between', value: { from: 10 * day } }) + expect(open({ due: 12 * day })).toBe(true) + expect(open({ due: 8 * day })).toBe(false) + expect(open({})).toBe(false) + }) + + it('date bounds are whole calendar days', () => { + const d = new Date(2026, 5, 10, 15, 30).getTime() + const before = buildFieldPredicate(date, { operator: 'before', value: d }) + const after = buildFieldPredicate(date, { operator: 'after', value: d }) + // Any time on the bound day is neither before nor after it + expect(before({ due: d })).toBe(false) + expect(after({ due: d })).toBe(false) + expect(before({ due: startOfDay(d) })).toBe(false) + expect(after({ due: endOfDay(d) })).toBe(false) + expect(before({ due: startOfDay(d) - 1 })).toBe(true) + expect(after({ due: endOfDay(d) + 1 })).toBe(true) + const between = buildFieldPredicate(date, { operator: 'between', value: { from: d, to: d } }) + expect(between({ due: startOfDay(d) })).toBe(true) + expect(between({ due: endOfDay(d) })).toBe(true) + expect(between({ due: endOfDay(d) + 1 })).toBe(false) + }) + + it('select any-of works for single and multi select and ignores stale options', () => { + const s = buildFieldPredicate(single, { operator: 'anyOf', value: ['s', 'l'] }) + expect(s({ size: 'l' })).toBe(true) + expect(s({ size: 'm' })).toBe(false) + expect(s({ size: 'removed' })).toBe(false) + const m = buildFieldPredicate(multi, { operator: 'anyOf', value: ['b'] }) + expect(m({ tags: ['a', 'b'] })).toBe(true) + expect(m({ tags: ['a', 'c'] })).toBe(false) + expect(m({})).toBe(false) + }) + + it('is-empty and is-not-empty treat missing, null, stale and empty array as empty', () => { + const empty = buildFieldPredicate(single, { operator: 'isEmpty' }) + expect(empty({})).toBe(true) + expect(empty({ size: null })).toBe(true) + expect(empty({ size: 'removed' })).toBe(true) + expect(empty({ size: 'm' })).toBe(false) + const notEmpty = buildFieldPredicate(multi, { operator: 'isNotEmpty' }) + expect(notEmpty({ tags: [] })).toBe(false) + expect(notEmpty({ tags: ['a'] })).toBe(true) + expect(buildFieldPredicate(num, { operator: 'isEmpty' })({ points: 0 })).toBe(false) + }) + + it('incomplete rules do not restrict the result', () => { + expect(isFilterComplete({ operator: 'contains', value: ' ' })).toBe(false) + expect(isFilterComplete({ operator: 'anyOf', value: [] })).toBe(false) + expect(isFilterComplete({ operator: 'between', value: {} })).toBe(false) + expect(isFilterComplete({ operator: 'isEmpty' })).toBe(true) + expect(buildFieldPredicate(text, { operator: 'contains', value: '' })({ note: 'x' })).toBe(true) + }) + + it('combines rules with AND and a rule on a missing field matches nothing', () => { + const byKey = new Map([ + ['points', num], + ['size', single] + ]) + const filters: CustomFieldFilter[] = [ + { id: '1', fieldKey: 'points', operator: 'gte', value: 3 }, + { id: '2', fieldKey: 'size', operator: 'anyOf', value: ['m'] } + ] + const pred = buildFiltersPredicate(byKey, filters) + expect(pred({ customFields: { points: 5, size: 'm' } })).toBe(true) + expect(pred({ customFields: { points: 1, size: 'm' } })).toBe(false) + expect(pred({ customFields: { points: 5, size: 's' } })).toBe(false) + expect(pred({})).toBe(false) + expect(buildFiltersPredicate(byKey, [])({})).toBe(true) + const stale = buildFiltersPredicate(byKey, [{ id: '3', fieldKey: 'gone', operator: 'isEmpty' }]) + expect(stale({ customFields: {} })).toBe(false) + expect(activeFilterCount([...filters, { id: '4', fieldKey: 'size', operator: 'anyOf', value: [] }])).toBe(2) + }) + + it('lists operators per type', () => { + expect(operatorsFor(ProjectFieldType.Text)).toContain('contains') + expect(operatorsFor(ProjectFieldType.Number)).toContain('between') + expect(operatorsFor(ProjectFieldType.Date)).toContain('before') + expect(operatorsFor(ProjectFieldType.MultiSelect)).toContain('anyOf') + expect(operatorsFor(ProjectFieldType.Iteration)).toEqual([]) + }) +}) + +describe('custom field sorting', () => { + const sortIds = (f: ProjectField, dir: 1 | -1, items: Array<{ id: string, customFields?: Record }>) => + [...items].sort(buildFieldComparator(f, dir)).map((i) => i.id) + + it('sorts text with locale compare and puts empty last in both directions', () => { + const items = [ + { id: 'b', customFields: { note: 'beta' } }, + { id: 'none' }, + { id: 'a', customFields: { note: 'Alpha' } }, + { id: 'c', customFields: { note: 'gamma' } } + ] + expect(sortIds(text, 1, items)).toEqual(['a', 'b', 'c', 'none']) + expect(sortIds(text, -1, items)).toEqual(['c', 'b', 'a', 'none']) + }) + + it('sorts numbers numerically (not as strings) and keeps zero', () => { + const items = [ + { id: '10', customFields: { points: 10 } }, + { id: '2', customFields: { points: 2 } }, + { id: '0', customFields: { points: 0 } }, + { id: 'none', customFields: {} } + ] + expect(sortIds(num, 1, items)).toEqual(['0', '2', '10', 'none']) + expect(sortIds(num, -1, items)).toEqual(['10', '2', '0', 'none']) + }) + + it('sorts dates chronologically', () => { + const items = [ + { id: 'late', customFields: { due: 9 * day } }, + { id: 'early', customFields: { due: 1 * day } }, + { id: 'none' } + ] + expect(sortIds(date, 1, items)).toEqual(['early', 'late', 'none']) + expect(sortIds(date, -1, items)).toEqual(['late', 'early', 'none']) + }) + + it('sorts single select by option order', () => { + const items = [ + { id: 'l', customFields: { size: 'l' } }, + { id: 's', customFields: { size: 's' } }, + { id: 'm', customFields: { size: 'm' } }, + { id: 'stale', customFields: { size: 'removed' } } + ] + expect(sortIds(single, 1, items)).toEqual(['s', 'm', 'l', 'stale']) + expect(sortIds(single, -1, items)).toEqual(['l', 'm', 's', 'stale']) + }) +}) + +describe('custom field grouping', () => { + const docs = [ + { customFields: { size: 'l' } }, + { customFields: { size: 's' } }, + {}, + { customFields: { size: 'weird' } } + ] + + it('orders groups by option order with stray values and the empty group last', () => { + expect(buildGroupCategories(single, docs, false)).toEqual(['s', 'l', 'weird', undefined]) + }) + + it('omits the empty group when every issue has a value', () => { + expect(buildGroupCategories(single, [{ customFields: { size: 'm' } }], false)).toEqual(['m']) + }) + + it('lists empty options and the empty group when asked to', () => { + expect(buildGroupCategories(single, [{ customFields: { size: 'm' } }], true)).toEqual(['s', 'm', 'l', undefined]) + }) +}) + +describe('scan limit and view keys', () => { + it('resolves the configured limit', () => { + expect(resolveScanLimit(undefined)).toBe(DEFAULT_CUSTOM_FIELD_SCAN_LIMIT) + expect(resolveScanLimit('123')).toBe(123) + expect(resolveScanLimit(-5)).toBe(DEFAULT_CUSTOM_FIELD_SCAN_LIMIT) + expect(resolveScanLimit('abc')).toBe(DEFAULT_CUSTOM_FIELD_SCAN_LIMIT) + expect(resolveScanLimit(1.5)).toBe(DEFAULT_CUSTOM_FIELD_SCAN_LIMIT) + }) + + it('flags only scans that exceed the limit', () => { + expect(exceedsScanLimit(5000, 5000)).toBe(false) + expect(exceedsScanLimit(5001, 5000)).toBe(true) + }) + + it('round-trips view keys', () => { + expect(toCustomFieldViewKey('size')).toBe('customFields.size') + expect(parseCustomFieldViewKey('customFields.size')).toBe('size') + expect(parseCustomFieldViewKey('customFields.')).toBeUndefined() + expect(parseCustomFieldViewKey('status')).toBeUndefined() + }) +}) diff --git a/plugins/tracker-resources/src/projectFields/actions.ts b/plugins/tracker-resources/src/projectFields/actions.ts new file mode 100644 index 0000000000..ee6ea56a65 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/actions.ts @@ -0,0 +1,30 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { TxOperations } from '@hcengineering/core' +import type { Issue, ProjectFieldValue } from '@hcengineering/tracker' + +import { mergeCustomFieldValue } from './registry' + +/** + * Store one custom field value on an issue. An empty value removes the key. + */ +export async function setIssueCustomFieldValue ( + client: TxOperations, + issue: Pick, + key: string, + value: ProjectFieldValue +): Promise { + const customFields = mergeCustomFieldValue(issue.customFields, key, value) + await client.updateCollection( + issue._class, + issue.space, + issue._id, + issue.attachedTo, + issue.attachedToClass, + issue.collection, + { customFields } + ) +} diff --git a/plugins/tracker-resources/src/projectFields/customFieldView.ts b/plugins/tracker-resources/src/projectFields/customFieldView.ts new file mode 100644 index 0000000000..0ef0183c8f --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/customFieldView.ts @@ -0,0 +1,145 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { SortingOrder, type CategoryType, type Doc, type Ref } from '@hcengineering/core' +import type { Project, ProjectField } from '@hcengineering/tracker' +import { ProjectFieldType } from '@hcengineering/tracker' +import type { ClientViewExtension } from '@hcengineering/view-resources' +import { writable, type Writable } from 'svelte/store' + +import tracker from '../plugin' +import CustomFieldGroupHeader from './CustomFieldGroupHeader.svelte' +import { + buildFieldComparator, + buildGroupCategories, + CUSTOM_FIELD_KEY_PREFIX, + isGroupableType, + isSortableType, + parseCustomFieldViewKey, + toCustomFieldViewKey, + type CustomFieldFilter +} from './query' +import type { ProjectFieldRegistry } from './registry' + +export interface CustomFieldViewParams { + registry: ProjectFieldRegistry + scanLimit: number + // Custom-field sort/group are switched off, e.g. because the view holds more issues than the scan limit + disabled: boolean + // "No " group labels by field key + emptyLabels: ReadonlyMap +} + +type IssueLike = Doc & { customFields?: Record } + +function headerPresenterFor (field: ProjectField): any { + const options = field.options ?? [] + // The list renders the header as `new Presenter({ props: { value, ... } })`, so the options are bound here + return class extends CustomFieldGroupHeader { + constructor (opts: any) { + super({ ...opts, props: { ...opts.props, options } }) + } + } +} + +/** + * View extension that exposes the custom fields of a project to the issue list as optional columns + * and as group-by / order-by keys. Grouping and ordering are evaluated on the client (plan D1). + */ +export function createCustomFieldViewExtension (params: CustomFieldViewParams): ClientViewExtension { + const { registry, scanLimit, disabled, emptyLabels } = params + const fieldOf = (key: string): ProjectField | undefined => { + const fieldKey = parseCustomFieldViewKey(key) + return fieldKey === undefined ? undefined : registry.byKey.get(fieldKey) + } + const headers = new Map() + + return { + // Any key of this form is ours, even if its field does not exist (any more), so a saved + // group/order choice can never reach the server as an unknown attribute + handlesKey: (key) => key.startsWith(CUSTOM_FIELD_KEY_PREFIX), + groupByKeys: () => + registry.fields + .filter((f) => isGroupableType(f.type)) + .map((f) => ({ id: toCustomFieldViewKey(f.key), label: f.label })), + orderByKeys: () => + registry.fields + .filter((f) => isSortableType(f.type)) + .map((f) => ({ id: toCustomFieldViewKey(f.key), label: f.label })), + columns: () => + registry.fields + .filter((f) => f.type !== ProjectFieldType.Iteration) + .map((f) => ({ + key: { + key: '', + presenter: tracker.component.CustomFieldColumn, + props: { fieldKey: f.key }, + displayProps: { key: `cf_${f.key}`, optional: true } + }, + label: f.label + })), + projectionKey: () => 'customFields', + getCategories: (key, docs, viewOptions): CategoryType[] => { + const field = fieldOf(key) + if (field === undefined || disabled || !isGroupableType(field.type)) return [undefined] + return buildGroupCategories(field, docs as IssueLike[], viewOptions.shouldShowAll === true) + }, + getGroupHeader: (key) => { + const field = fieldOf(key) + if (field === undefined) return undefined + let presenter = headers.get(key) + if (presenter === undefined) { + presenter = headerPresenterFor(field) + headers.set(key, presenter) + } + return presenter + }, + emptyGroupLabel: (key) => { + const fieldKey = parseCustomFieldViewKey(key) + return fieldKey === undefined ? undefined : emptyLabels.get(fieldKey) + }, + compare: (key, order: SortingOrder) => { + const field = fieldOf(key) + if (field === undefined || disabled || !isSortableType(field.type)) return undefined + const cmp = buildFieldComparator(field, order === SortingOrder.Ascending ? 1 : -1) + return (a, b) => cmp(a as IssueLike, b as IssueLike) + }, + scanLimit + } +} + +const filterStores = new Map, Writable>() +const storageKey = (project: Ref): string => `tracker.customFieldFilters.${project}` + +function loadFilters (project: Ref): CustomFieldFilter[] { + try { + const raw = localStorage.getItem(storageKey(project)) + const parsed = raw !== null ? JSON.parse(raw) : [] + return Array.isArray(parsed) ? parsed.filter((f) => typeof f?.fieldKey === 'string') : [] + } catch { + return [] + } +} + +/** + * Custom-field filter rules of a project view. Kept for the session and mirrored to local storage + * so that a reload does not silently drop the filter. + */ +export function customFieldFilterStore (project: Ref): Writable { + let store = filterStores.get(project) + if (store === undefined) { + store = writable(loadFilters(project)) + store.subscribe((value) => { + try { + if (value.length === 0) localStorage.removeItem(storageKey(project)) + else localStorage.setItem(storageKey(project), JSON.stringify(value)) + } catch { + // Storage can be unavailable (private mode, quota); the in-memory state still works + } + }) + filterStores.set(project, store) + } + return store +} diff --git a/plugins/tracker-resources/src/projectFields/projectFieldsStore.ts b/plugins/tracker-resources/src/projectFields/projectFieldsStore.ts index 6650c0ba05..a784122c05 100644 --- a/plugins/tracker-resources/src/projectFields/projectFieldsStore.ts +++ b/plugins/tracker-resources/src/projectFields/projectFieldsStore.ts @@ -26,3 +26,18 @@ export function projectFieldsStore (project: Ref): Readable, Readable>() + +/** + * Same as `projectFieldsStore`, but one store (and so one live query) per project is shared by all + * callers. Use it where many components need the registry at once, e.g. one cell per list row. + */ +export function sharedProjectFieldsStore (project: Ref): Readable { + let store = sharedStores.get(project) + if (store === undefined) { + store = projectFieldsStore(project) + sharedStores.set(project, store) + } + return store +} diff --git a/plugins/tracker-resources/src/projectFields/query.ts b/plugins/tracker-resources/src/projectFields/query.ts new file mode 100644 index 0000000000..e9f58920ed --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/query.ts @@ -0,0 +1,326 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { ProjectField } from '@hcengineering/tracker' +import { getFieldValue, ProjectFieldType } from '@hcengineering/tracker' + +/** + * Pure helpers for filtering, sorting and grouping issues by custom field values. + * Values live in an untyped `Issue.customFields` record (plan D1), so all of this runs on + * the client over a bounded set of issues, see `DEFAULT_CUSTOM_FIELD_SCAN_LIMIT`. + */ + +/** Prefix of the view-option keys (groupBy / orderBy) that address a custom field. */ +export const CUSTOM_FIELD_KEY_PREFIX = 'customFields.' + +/** Default value of the `TRACKER_CUSTOM_FIELD_SCAN_LIMIT` setting. */ +export const DEFAULT_CUSTOM_FIELD_SCAN_LIMIT = 5000 + +/** + * Normalize a configured scan limit. Anything that is not a positive integer falls back to the default. + */ +export function resolveScanLimit (raw: unknown): number { + const n = typeof raw === 'string' ? Number(raw) : raw + return typeof n === 'number' && Number.isInteger(n) && n > 0 ? n : DEFAULT_CUSTOM_FIELD_SCAN_LIMIT +} + +/** + * Custom-field filter/sort/group works on a scanned window of `limit` issues. When the scan returned + * more than that, the features must be disabled instead of silently working on a truncated set. + * `scanned` is the size of a scan that was capped at `limit + 1`. + */ +export function exceedsScanLimit (scanned: number, limit: number): boolean { + return scanned > limit +} + +export function toCustomFieldViewKey (fieldKey: string): string { + return `${CUSTOM_FIELD_KEY_PREFIX}${fieldKey}` +} + +/** + * Field key addressed by a view-option key, or undefined for regular attributes. + */ +export function parseCustomFieldViewKey (key: string): string | undefined { + return key.startsWith(CUSTOM_FIELD_KEY_PREFIX) && key.length > CUSTOM_FIELD_KEY_PREFIX.length + ? key.slice(CUSTOM_FIELD_KEY_PREFIX.length) + : undefined +} + +/** @public */ +export type FieldFilterOperator = + | 'contains' + | 'eq' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'between' + | 'before' + | 'after' + | 'anyOf' + | 'isEmpty' + | 'isNotEmpty' + +export interface FieldRange { + from?: number + to?: number +} + +export type FieldFilterValue = string | number | string[] | FieldRange | undefined + +/** + * One rule of the custom-field filter. Rules are combined with AND. + */ +export interface CustomFieldFilter { + id: string + fieldKey: string + operator: FieldFilterOperator + value?: FieldFilterValue +} + +const EMPTY_OPERATORS: FieldFilterOperator[] = ['isEmpty', 'isNotEmpty'] + +/** + * Operators offered for a field type. The first one is the default. + */ +export function operatorsFor (type: ProjectFieldType): FieldFilterOperator[] { + switch (type) { + case ProjectFieldType.Text: + return ['contains', ...EMPTY_OPERATORS] + case ProjectFieldType.Number: + return ['eq', 'gt', 'gte', 'lt', 'lte', 'between', ...EMPTY_OPERATORS] + case ProjectFieldType.Date: + return ['before', 'after', 'between', ...EMPTY_OPERATORS] + case ProjectFieldType.SingleSelect: + case ProjectFieldType.MultiSelect: + return ['anyOf', ...EMPTY_OPERATORS] + default: + return [] + } +} + +/** Field types that can be filtered on. Iteration gets its own support in a later phase. */ +export function isFilterableType (type: ProjectFieldType): boolean { + return operatorsFor(type).length > 0 +} + +/** Field types that can be sorted on. Multi-select has no meaningful order (GitHub parity). */ +export function isSortableType (type: ProjectFieldType): boolean { + return ( + type === ProjectFieldType.Text || + type === ProjectFieldType.Number || + type === ProjectFieldType.Date || + type === ProjectFieldType.SingleSelect + ) +} + +/** Only single-select fields can be grouped by (GitHub parity). */ +export function isGroupableType (type: ProjectFieldType): boolean { + return type === ProjectFieldType.SingleSelect +} + +function isRange (value: FieldFilterValue): value is FieldRange { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * A rule without a usable value is a draft the user is still editing; it must not affect the result. + */ +export function isFilterComplete (filter: Pick): boolean { + if (EMPTY_OPERATORS.includes(filter.operator)) return true + const { value } = filter + switch (filter.operator) { + case 'contains': + return typeof value === 'string' && value.trim() !== '' + case 'eq': + case 'gt': + case 'gte': + case 'lt': + case 'lte': + case 'before': + case 'after': + return typeof value === 'number' && Number.isFinite(value) + case 'between': + return isRange(value) && (typeof value.from === 'number' || typeof value.to === 'number') + case 'anyOf': + return Array.isArray(value) && value.length > 0 + default: + return false + } +} + +/** Start of the local calendar day containing the timestamp. */ +export function startOfDay (ts: number): number { + return new Date(ts).setHours(0, 0, 0, 0) +} + +/** Last millisecond of the local calendar day containing the timestamp. */ +export function endOfDay (ts: number): number { + return new Date(ts).setHours(23, 59, 59, 999) +} + +function isEmptyValue (value: unknown): boolean { + return value === null || value === undefined || (Array.isArray(value) && value.length === 0) +} + +export type CustomFieldsRecord = Record | undefined + +/** + * Build a predicate over `Issue.customFields` for one filter rule. + * Incomplete rules match everything. Date bounds are whole calendar days: "before D" excludes D, + * "after D" starts the day after D, and a range includes both end days. + */ +export function buildFieldPredicate ( + field: Pick, + filter: Pick +): (customFields: CustomFieldsRecord) => boolean { + if (!isFilterComplete(filter)) return () => true + const read = (cf: CustomFieldsRecord): ReturnType => getFieldValue(cf, field) + const { operator, value } = filter + + if (operator === 'isEmpty') return (cf) => isEmptyValue(read(cf)) + if (operator === 'isNotEmpty') return (cf) => !isEmptyValue(read(cf)) + + if (operator === 'contains') { + const needle = (value as string).trim().toLowerCase() + return (cf) => { + const v = read(cf) + return typeof v === 'string' && v.toLowerCase().includes(needle) + } + } + + if (operator === 'anyOf') { + const wanted = new Set(value as string[]) + return (cf) => { + const v = read(cf) + if (Array.isArray(v)) return v.some((x) => wanted.has(x)) + return typeof v === 'string' && wanted.has(v) + } + } + + const isDate = field.type === ProjectFieldType.Date + + if (operator === 'between') { + const range = value as FieldRange + const from = isDate && range.from !== undefined ? startOfDay(range.from) : range.from + const to = isDate && range.to !== undefined ? endOfDay(range.to) : range.to + return (cf) => { + const v = read(cf) + if (typeof v !== 'number') return false + return (from === undefined || v >= from) && (to === undefined || v <= to) + } + } + + const bound = value as number + const dayStart = isDate ? startOfDay(bound) : bound + const dayEnd = isDate ? endOfDay(bound) : bound + const compare = (check: (v: number) => boolean) => (cf: CustomFieldsRecord) => { + const v = read(cf) + return typeof v === 'number' && check(v) + } + switch (operator) { + case 'eq': + return compare((v) => v === bound) + case 'gt': + return compare((v) => v > bound) + case 'after': + return compare((v) => v > dayEnd) + case 'gte': + return compare((v) => v >= bound) + case 'lt': + return compare((v) => v < bound) + case 'before': + return compare((v) => v < dayStart) + case 'lte': + return compare((v) => v <= bound) + default: + return () => true + } +} + +/** + * Combine all rules with AND. A rule that refers to a field that no longer exists matches nothing, + * so a stale rule is never silently ignored. + */ +export function buildFiltersPredicate ( + fieldsByKey: ReadonlyMap>, + filters: readonly CustomFieldFilter[] +): (issue: { customFields?: Record }) => boolean { + const predicates = filters + .filter((f) => isFilterComplete(f)) + .map((f) => { + const field = fieldsByKey.get(f.fieldKey) + return field === undefined ? () => false : buildFieldPredicate(field, f) + }) + if (predicates.length === 0) return () => true + return (issue) => predicates.every((p) => p(issue.customFields)) +} + +/** Number of rules that actually restrict the result. */ +export function activeFilterCount (filters: readonly CustomFieldFilter[]): number { + return filters.filter((f) => isFilterComplete(f)).length +} + +/** Comparator for two non-empty values of the field. */ +function compareNonEmpty (field: Pick): (a: any, b: any) => number { + switch (field.type) { + case ProjectFieldType.Number: + case ProjectFieldType.Date: + return (a: number, b: number) => a - b + case ProjectFieldType.SingleSelect: { + // GitHub orders single-select values by the option order, not alphabetically + const order = new Map((field.options ?? []).map((o, i) => [o.value, i])) + return (a: string, b: string) => (order.get(a) ?? 0) - (order.get(b) ?? 0) + } + default: + return (a: string, b: string) => a.localeCompare(b) + } +} + +/** + * Comparator for issues by a custom field. `direction` is 1 (ascending) or -1 (descending). + * Empty values are always placed last, in both directions. + */ +export function buildFieldComparator ( + field: Pick, + direction: 1 | -1 +): (a: { customFields?: Record }, b: { customFields?: Record }) => number { + const cmp = compareNonEmpty(field) + return (a, b) => { + const va = getFieldValue(a.customFields, field) + const vb = getFieldValue(b.customFields, field) + const ea = isEmptyValue(va) + const eb = isEmptyValue(vb) + if (ea || eb) return ea === eb ? 0 : ea ? 1 : -1 + return cmp(va, vb) * direction + } +} + +/** + * Group values (option ids) in display order for a single-select field: options in their defined + * order, followed by any stray value found in the data, and `undefined` (the "No " group) last. + * Options without issues are only listed when `includeEmpty` is set. + */ +export function buildGroupCategories ( + field: Pick, + docs: ReadonlyArray<{ customFields?: Record }>, + includeEmpty: boolean +): Array { + const used = new Set() + let hasEmpty = false + for (const doc of docs) { + const v = doc.customFields?.[field.key] + if (typeof v === 'string' && v !== '') used.add(v) + else hasEmpty = true + } + const result: Array = [] + for (const o of field.options ?? []) { + if (includeEmpty || used.has(o.value)) result.push(o.value) + used.delete(o.value) + } + result.push(...used) + if (hasEmpty || includeEmpty) result.push(undefined) + return result +} diff --git a/plugins/view-resources/src/clientViewExtension.ts b/plugins/view-resources/src/clientViewExtension.ts new file mode 100644 index 0000000000..b5f7ae57d3 --- /dev/null +++ b/plugins/view-resources/src/clientViewExtension.ts @@ -0,0 +1,76 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { CategoryType, Doc, SortingOrder } from '@hcengineering/core' +import type { AnySvelteComponent } from '@hcengineering/ui' +import type { BuildModelKey, ViewOptions } from '@hcengineering/view' +import { get, writable } from 'svelte/store' + +/** + * Option offered in the Group-by / Order-by dropdowns. + * @public + */ +export interface ClientViewKey { + id: string + // Plain (already translated) label + label: string +} + +/** + * Optional list column offered in "Configure columns". + * @public + */ +export interface ClientViewColumn { + key: BuildModelKey + // Plain (already translated) label + label: string +} + +/** + * Lets a host plugin provide group-by / order-by keys and columns that are not model attributes + * (e.g. user-defined fields stored in an untyped record). Grouping and ordering by such keys is + * performed on the client over the loaded documents. + * @public + */ +export interface ClientViewExtension { + // True when the key (used in viewOptions.groupBy / orderBy) is owned by the extension + handlesKey: (key: string) => boolean + groupByKeys: () => ClientViewKey[] + orderByKeys: () => ClientViewKey[] + columns: () => ClientViewColumn[] + // Top-level document property that has to be loaded to evaluate the key + projectionKey: (key: string) => string + // Ordered categories (group values) for the documents + getCategories: (key: string, docs: Doc[], viewOptions: ViewOptions) => CategoryType[] + // Presenter of a group header, receives the category as `value` + getGroupHeader: (key: string) => AnySvelteComponent | undefined + // Label of the group of documents without a value + emptyGroupLabel: (key: string) => string | undefined + // Client-side comparator, undefined when there is nothing to sort by + compare: (key: string, order: SortingOrder) => ((a: Doc, b: Doc) => number) | undefined + // Upper bound of documents to load per group when sorting on the client + scanLimit: number +} + +/** + * Extension of the currently displayed view, if any. + * @public + */ +export const clientViewExtension = writable(undefined) + +/** + * @public + */ +export function getClientViewExtension (): ClientViewExtension | undefined { + return get(clientViewExtension) +} + +/** + * Whether the key is handled by the active extension. + * @public + */ +export function isClientViewKey (ext: ClientViewExtension | undefined, key: string | undefined): boolean { + return ext !== undefined && key !== undefined && ext.handlesKey(key) +} diff --git a/plugins/view-resources/src/components/ViewOptions.svelte b/plugins/view-resources/src/components/ViewOptions.svelte index ee94f92d0e..96e89cda50 100644 --- a/plugins/view-resources/src/components/ViewOptions.svelte +++ b/plugins/view-resources/src/components/ViewOptions.svelte @@ -2,11 +2,14 @@ import { getClient } from '@hcengineering/presentation' import { DropdownIntlItem, DropdownLabelsIntl, Label, Toggle } from '@hcengineering/ui' import { Viewlet, ViewOptions, ViewOptionsModel, ViewOptionModel } from '@hcengineering/view' + import type { IntlString } from '@hcengineering/platform' import { createEventDispatcher } from 'svelte' import view from '../plugin' import { buildConfigLookup, canResolveAttribute, getKeyLabel } from '../utils' import { isDropdownType, isToggleType, noCategory } from '../viewOptions' import { SortingOrder } from '@hcengineering/core' + import { getEmbeddedLabel } from '@hcengineering/platform' + import { getClientViewExtension, isClientViewKey } from '../clientViewExtension' export let viewlet: Viewlet export let config: ViewOptionsModel @@ -37,23 +40,35 @@ const hierarchy = client.getHierarchy() const lookup = buildConfigLookup(hierarchy, viewlet.attachTo, viewlet.config, viewlet.options?.lookup) + const extension = getClientViewExtension() + const extensionLabels = new Map( + [...(extension?.groupByKeys() ?? []), ...(extension?.orderByKeys() ?? [])].map((it) => [it.id, it.label]) + ) + const getLabel = (key: string): IntlString => + isClientViewKey(extension, key) + ? getEmbeddedLabel(extensionLabels.get(key) ?? key) + : getKeyLabel(client, viewlet.attachTo, key, lookup) + const groupBy = config.groupBy - .filter((p) => canResolveAttribute(hierarchy, viewlet.attachTo, p, lookup)) + .filter((p) => isClientViewKey(extension, p) || canResolveAttribute(hierarchy, viewlet.attachTo, p, lookup)) .map((p) => { return { id: p, - label: getKeyLabel(client, viewlet.attachTo, p, lookup) + label: getLabel(p) } }) .concat({ id: noCategory, label: view.string.NoGrouping }) const orderBy = config.orderBy - .filter((p) => p[0] === 'rank' || canResolveAttribute(hierarchy, viewlet.attachTo, p[0], lookup)) + .filter( + (p) => + p[0] === 'rank' || isClientViewKey(extension, p[0]) || canResolveAttribute(hierarchy, viewlet.attachTo, p[0], lookup) + ) .map((p) => { const key = p[0] return { id: key, - label: key === 'rank' ? view.string.Manual : getKeyLabel(client, viewlet.attachTo, key, lookup) + label: key === 'rank' ? view.string.Manual : getLabel(key) } }) diff --git a/plugins/view-resources/src/components/ViewOptionsButton.svelte b/plugins/view-resources/src/components/ViewOptionsButton.svelte index 81ae676c97..6f3472237b 100644 --- a/plugins/view-resources/src/components/ViewOptionsButton.svelte +++ b/plugins/view-resources/src/components/ViewOptionsButton.svelte @@ -20,6 +20,7 @@ import view from '../plugin' import { focusStore } from '../selection' import { setViewOptions } from '../viewOptions' + import { getClientViewExtension } from '../clientViewExtension' import ViewOptionsEditor from './ViewOptions.svelte' import core, { Class, Doc, Hierarchy, Ref, SortingOrder, Type } from '@hcengineering/core' @@ -88,6 +89,14 @@ config.groupBy = Array.from(new Set([...config.groupBy, ...customAttributes])) + // Keys provided by the host view (e.g. user-defined fields), supported by the list only + const extension = viewlet.descriptor === view.viewlet.List ? getClientViewExtension() : undefined + if (extension !== undefined) { + config.groupBy = Array.from(new Set([...config.groupBy, ...extension.groupByKeys().map((it) => it.id)])) + const extraOrder: OrderOption[] = extension.orderByKeys().map((it) => [it.id, SortingOrder.Ascending]) + config.orderBy = [...config.orderBy, ...extraOrder] + } + showPopup( ViewOptionsEditor, { viewlet, config, viewOptions: h.clone(viewOptions), hideGroupingAndOrdering, hideKeys }, diff --git a/plugins/view-resources/src/components/ViewletSetting.svelte b/plugins/view-resources/src/components/ViewletSetting.svelte index 4ee033794b..52d7fe165a 100644 --- a/plugins/view-resources/src/components/ViewletSetting.svelte +++ b/plugins/view-resources/src/components/ViewletSetting.svelte @@ -32,6 +32,7 @@ import { createEventDispatcher } from 'svelte' import view from '../plugin' import { buildConfigLookup, canResolveAttribute, getKeyLabel } from '../utils' + import { getClientViewExtension } from '../clientViewExtension' import ViewletClassSettings from './ViewletClassSettings.svelte' export let viewlet: Viewlet @@ -449,6 +450,25 @@ await addAssociations(result, viewlet.attachTo, preference) } + // Optional columns provided by the host view (e.g. user-defined fields) + const extension = getClientViewExtension() + if (extension !== undefined) { + const clazz = hierarchy.getClass(viewlet.attachTo) + for (const column of extension.columns()) { + const columnConfig: AttributeConfig = { + type: 'attribute', + value: column.key, + label: getEmbeddedLabel(column.label), + enabled: false, + _class: viewlet.attachTo, + icon: clazz.icon + } + if (!isExist(result, columnConfig)) { + result.push(columnConfig) + } + } + } + return preference === undefined ? result : setStatus(result, preference) } diff --git a/plugins/view-resources/src/components/list/List.svelte b/plugins/view-resources/src/components/list/List.svelte index 859779d70f..054768d3f7 100644 --- a/plugins/view-resources/src/components/list/List.svelte +++ b/plugins/view-resources/src/components/list/List.svelte @@ -30,6 +30,7 @@ import { createEventDispatcher, onDestroy } from 'svelte' import { SelectionFocusProvider } from '../../selection' import { claimResultCountOwner, releaseResultCountOwner, setResultCount } from '../../stores' + import { clientViewExtension, isClientViewKey } from '../../clientViewExtension' import { buildConfigLookup } from '../../utils' import { getResultOptions, getResultQuery } from '../../viewOptions' import ListCategories from './ListCategories.svelte' @@ -105,7 +106,10 @@ $: resultOptions = { ...configOptions, ...(Object.keys(lookup).length > 0 ? { lookup } : {}), - ...(orderBy !== undefined ? { sort: { [orderBy[0]]: orderBy[1] } } : {}) + // Ordering by a client-side key (e.g. a custom field) is applied by the list category after loading + ...(orderBy !== undefined && !isClientViewKey($clientViewExtension, orderBy[0]) + ? { sort: { [orderBy[0]]: orderBy[1] } } + : {}) } const updateOptions = reduceCalls(async function (options: FindOptions | undefined, viewOptions: ViewOptions) { @@ -148,7 +152,11 @@ ...resultOptions.projection, _id: 1, _class: 1, - ...getProjection(viewOptions.groupBy, queryNoLookup, _class) + ...getProjection( + viewOptions.groupBy.map((it) => (isClientViewKey($clientViewExtension, it) ? $clientViewExtension?.projectionKey(it) ?? it : it)), + queryNoLookup, + _class + ) } } diff --git a/plugins/view-resources/src/components/list/ListCategories.svelte b/plugins/view-resources/src/components/list/ListCategories.svelte index 3344c7e606..f0616bcd84 100644 --- a/plugins/view-resources/src/components/list/ListCategories.svelte +++ b/plugins/view-resources/src/components/list/ListCategories.svelte @@ -49,6 +49,7 @@ groupBy } from '../../utils' import { CategoryQuery, noCategory } from '../../viewOptions' + import { clientViewExtension, isClientViewKey, type ClientViewExtension } from '../../clientViewExtension' import ListCategory from './ListCategory.svelte' export let docs: Doc[] @@ -91,7 +92,7 @@ $: groupByKey = viewOptions.groupBy[level] ?? noCategory let categories: CategoryType[] = [] - $: void updateCategories(_class, space, docs, groupByKey, viewOptions, viewOptionsConfig) + $: void updateCategories(_class, space, docs, groupByKey, viewOptions, viewOptionsConfig, $clientViewExtension) $: groupByDocs = groupBy(docs, groupByKey, categories) @@ -110,8 +111,14 @@ docs: Doc[], groupByKey: string, viewOptions: ViewOptions, - viewOptionsModel: ViewOptionModel[] | undefined + viewOptionsModel: ViewOptionModel[] | undefined, + extension: ClientViewExtension | undefined ): Promise => { + if (extension !== undefined && isClientViewKey(extension, groupByKey)) { + // Grouping by a client-side key is computed from the loaded documents + categories = extension.getCategories(groupByKey, docs, viewOptions) + return + } categories = await getCategories(client, _class, space, docs, groupByKey) if (level === 0) { for (const viewOption of viewOptionsModel ?? []) { @@ -131,21 +138,42 @@ ) function update (): void { - void updateCategories(_class, space, docs, groupByKey, viewOptions, viewOptionsConfig) + void updateCategories(_class, space, docs, groupByKey, viewOptions, viewOptionsConfig, $clientViewExtension) } let itemModels = new Map>, AttributeModel[]>() - const getHeader = reduceCalls(async function (_class: Ref>, groupByKey: string): Promise { + const getHeader = reduceCalls(async function ( + _class: Ref>, + groupByKey: string, + // Only a trigger: the extension is re-read from the store inside + extension?: unknown + ): Promise { + void extension if (groupByKey === noCategory) { headerComponent = undefined + } else if (isClientViewKey($clientViewExtension, groupByKey)) { + const presenter = $clientViewExtension?.getGroupHeader(groupByKey) + headerComponent = + presenter === undefined + ? undefined + : { + key: groupByKey, + sortingKey: groupByKey, + _class, + label: '' as IntlString, + presenter, + props: {}, + collectionAttr: false, + isLookup: false + } } else { await getPresenter(client, _class, { key: groupByKey }, { key: groupByKey }).then((p) => (headerComponent = p)) } }) let headerComponent: AttributeModel | undefined - $: void getHeader(_class, groupByKey) + $: void getHeader(_class, groupByKey, $clientViewExtension) let configurationsVersion = 0 const buildModels = reduceCalls(async function ( @@ -367,8 +395,14 @@ function getGroupByKey ( docKeys: Partial>>, category: CategoryType, - resultQuery: DocumentQuery> + resultQuery: DocumentQuery>, + docsByGroup: Record, + extension: ClientViewExtension | undefined ): Partial> { + if (isClientViewKey(extension, groupByKey)) { + // Documents of a client-side group are addressed by id, the key is not queryable on the server + return { ...docKeys, _id: { $in: getGroupByValues(docsByGroup, category).map((it) => it._id) } } + } return { ...docKeys, [groupByKey]: @@ -385,7 +419,7 @@ {#each categories as category, i (typeof category === 'object' ? category.name : category)} {@const items = groupByKey === noCategory ? docs : getGroupByValues(groupByDocs, category)} - {@const categoryDocKeys = getGroupByKey(docKeys, category, resultQuery)} + {@const categoryDocKeys = getGroupByKey(docKeys, category, resultQuery, groupByDocs, $clientViewExtension)} { try { @@ -141,14 +149,14 @@ _class, { ...finalResultQuery, ...docKeys }, (res) => { - items = res + items = clientCompare !== undefined ? [...res].sort(clientCompare) : res loading = false const focusDoc = items.find((it) => it._id === $focusStore.focus?._id) if (focusDoc) { handleRowFocused(focusDoc) } }, - { ...resultOptions, limit: limit ?? 200 } + { ...resultOptions, limit: clientSorted ? ($clientViewExtension?.scanLimit ?? 5000) : (limit ?? 200) } ) } catch (e) { console.error(e) @@ -211,7 +219,7 @@ return { ...newObjectProps(doc), ...(doc ? { space: doc.space } : {}), - ...(groupValue !== undefined ? { [groupByKey]: groupValue } : {}) + ...(groupValue !== undefined && !isClientViewKey($clientViewExtension, groupByKey) ? { [groupByKey]: groupValue } : {}) } } diff --git a/plugins/view-resources/src/components/list/ListHeader.svelte b/plugins/view-resources/src/components/list/ListHeader.svelte index 2fac066863..a7b52587aa 100644 --- a/plugins/view-resources/src/components/list/ListHeader.svelte +++ b/plugins/view-resources/src/components/list/ListHeader.svelte @@ -39,6 +39,7 @@ import view from '../../plugin' import { SelectionFocusProvider, selectionLimit } from '../../selection' import { noCategory } from '../../viewOptions' + import { clientViewExtension, isClientViewKey } from '../../clientViewExtension' export let groupByKey: string export let category: PrimitiveType | AggregateValue @@ -137,7 +138,11 @@ {:else if category === undefined} - {:else if headerComponent} Date: Sat, 3 Oct 2026 15:58:23 +0530 Subject: [PATCH 17/32] feat(tracker): saved views with tabs, per-view config and unsaved-changes tracking Co-Authored-By: Claude Sonnet 5.5 --- models/view/src/index.ts | 4 + .../src/components/issues/IssuesView.svelte | 28 +- plugins/view-assets/lang/cs.json | 13 +- plugins/view-assets/lang/de.json | 13 +- plugins/view-assets/lang/en.json | 13 +- plugins/view-assets/lang/es.json | 13 +- plugins/view-assets/lang/fr.json | 13 +- plugins/view-assets/lang/it.json | 13 +- plugins/view-assets/lang/ja.json | 13 +- plugins/view-assets/lang/ko.json | 13 +- plugins/view-assets/lang/pl.json | 13 +- plugins/view-assets/lang/pt-br.json | 13 +- plugins/view-assets/lang/pt.json | 13 +- plugins/view-assets/lang/ru.json | 13 +- plugins/view-assets/lang/tr.json | 13 +- plugins/view-assets/lang/zh.json | 13 +- .../src/__tests__/savedViews.test.ts | 185 +++++ .../src/components/ViewletContentView.svelte | 4 +- .../src/components/ViewletSetting.svelte | 25 +- .../components/ViewletSettingButton.svelte | 7 +- .../src/components/view/SavedViewBar.svelte | 710 ++++++++++++++++++ plugins/view-resources/src/index.ts | 3 + plugins/view-resources/src/plugin.ts | 14 +- plugins/view-resources/src/savedViews.ts | 203 +++++ plugins/view-resources/src/viewOptions.ts | 17 + plugins/view/src/types.ts | 12 + 26 files changed, 1374 insertions(+), 20 deletions(-) create mode 100644 plugins/view-resources/src/__tests__/savedViews.test.ts create mode 100644 plugins/view-resources/src/components/view/SavedViewBar.svelte create mode 100644 plugins/view-resources/src/savedViews.ts diff --git a/models/view/src/index.ts b/models/view/src/index.ts index b5b501624c..3e7f2ab0df 100644 --- a/models/view/src/index.ts +++ b/models/view/src/index.ts @@ -133,6 +133,10 @@ export class TFilteredView extends TDoc implements FilteredView { users!: AccountUuid[] attachedTo!: string sharable?: boolean + project?: Ref + config?: (BuildModelKey | string)[] + order?: number + extra?: string } @Model(view.class.FilterMode, core.class.Doc, DOMAIN_MODEL) diff --git a/plugins/tracker-resources/src/components/issues/IssuesView.svelte b/plugins/tracker-resources/src/components/issues/IssuesView.svelte index d188a36674..76a6146c08 100644 --- a/plugins/tracker-resources/src/components/issues/IssuesView.svelte +++ b/plugins/tracker-resources/src/components/issues/IssuesView.svelte @@ -13,12 +13,13 @@ showPopup, themeStore } from '@hcengineering/ui' - import { ViewOptions, Viewlet } from '@hcengineering/view' + import view, { BuildModelKey, ViewOptions, Viewlet } from '@hcengineering/view' import { clientViewExtension, FilterBar, FilterButton, InlineFilterChips, + SavedViewBar, SpaceHeader, ViewletContentView, ViewletSettingButton, @@ -64,6 +65,12 @@ export let modeSelectorProps: IModeSelector | undefined = undefined let viewlet: WithLookup | undefined = undefined + const viewletQuery = { attachTo: tracker.class.Issue, variant: { $nin: ['subissue', 'component', 'milestone'] } } + // GitHub Projects layouts that exist in the tracker: Table (the list) and Board + const viewLayouts = [view.viewlet.List, tracker.viewlet.Kanban] + // Columns of the active saved view and its bar, which also keeps the unsaved column edits + let viewConfig: Array | undefined + let savedViewBar: SavedViewBar | undefined const viewlets: WithLookup[] | undefined = undefined let viewOptions: ViewOptions | undefined @@ -251,7 +258,7 @@ bind:viewlet bind:search showLabelSelector={$$slots.label_selector} - viewletQuery={{ attachTo: tracker.class.Issue, variant: { $nin: ['subissue', 'component', 'milestone'] } }} + {viewletQuery} {viewlets} {label} {space} @@ -267,6 +274,8 @@ hideGroupingAndOrdering={isGanttMode} showConfigureColumns={!isGanttMode} hideKeys={isGanttMode ? ['ganttGroupBy'] : []} + configOverride={project !== undefined ? viewConfig : undefined} + onSaveConfig={project !== undefined ? (config) => savedViewBar?.setLocalConfig(config) : undefined} /> @@ -358,6 +367,20 @@ +{#if project !== undefined} + + +{/if} + + + +{#if tabs.length > 0} +
+
+ {#each tabs as tab, index (tab._id)} + {@const selected = activeTab?._id === tab._id} + + {/each} + {#if !$restrictionStore.readonly && layoutViewlets.length > 0} +
+ {#if dirty && !$restrictionStore.readonly} +
+
+ {/if} +
+{/if} + + diff --git a/plugins/view-resources/src/index.ts b/plugins/view-resources/src/index.ts index 7467627c2b..6e1cee75c6 100644 --- a/plugins/view-resources/src/index.ts +++ b/plugins/view-resources/src/index.ts @@ -67,6 +67,7 @@ import IdPresenter from './components/IdPresenter.svelte' import ReadOnlyNotification from './components/ReadOnlyNotification.svelte' import RolePresenter from './components/RolePresenter.svelte' import SearchSelector from './components/SearchSelector.svelte' +import SavedViewBar from './components/view/SavedViewBar.svelte' import SpaceHeader from './components/SpaceHeader.svelte' import SpacePresenter from './components/SpacePresenter.svelte' import SpaceRefPresenter from './components/SpaceRefPresenter.svelte' @@ -231,6 +232,7 @@ export { } from './utils' export * from './viewOptions' export * from './clientViewExtension' +export * from './savedViews' export * from './viewletContextStore' export { getViewletSpecialActions } from './viewletUtils' export { copyMarkdown } from './actionImpl' @@ -263,6 +265,7 @@ export { SortableDocList, SortableList, SortableListItem, + SavedViewBar, SpaceHeader, SpacePresenter, StringEditor, diff --git a/plugins/view-resources/src/plugin.ts b/plugins/view-resources/src/plugin.ts index c82fbec831..064e2eb789 100644 --- a/plugins/view-resources/src/plugin.ts +++ b/plugins/view-resources/src/plugin.ts @@ -114,7 +114,19 @@ export default mergeIds(viewId, typedView, { NumberItems: '' as IntlString, ToViewCommands: '' as IntlString, NoRelations: '' as IntlString, - FilterOverflowBadge: '' as IntlString + FilterOverflowBadge: '' as IntlString, + + SavedViewNew: '' as IntlString, + SavedViewDefaultName: '' as IntlString, + SavedViewDuplicate: '' as IntlString, + SavedViewDelete: '' as IntlString, + SavedViewDeleteConfirm: '' as IntlString, + SavedViewSave: '' as IntlString, + SavedViewSaveAsNew: '' as IntlString, + SavedViewDiscard: '' as IntlString, + SavedViewUnsaved: '' as IntlString, + SavedViewLayoutTable: '' as IntlString, + SavedViewLayoutTo: '' as IntlString }, function: { CreateDocMiddleware: '' as Resource, diff --git a/plugins/view-resources/src/savedViews.ts b/plugins/view-resources/src/savedViews.ts new file mode 100644 index 0000000000..b42d5c233c --- /dev/null +++ b/plugins/view-resources/src/savedViews.ts @@ -0,0 +1,203 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Pure logic of project saved views (GitHub-Projects style view tabs). Kept free of UI and +// platform imports so that it can be unit tested. + +import type { BuildModelKey, ViewOptions } from '@hcengineering/view' +import { deepEqual } from 'fast-equals' + +export type ViewColumns = Array + +/** + * One layer of view configuration: the saved view, or the unsaved local edits on top of it. + * Every field is optional; an absent field means "not specified by this layer". + */ +export interface ViewConfigLayer { + // Layout (viewlet) of the view + viewletId?: string | null + // JSON of Filter[] + filters?: string + viewOptions?: ViewOptions + // Ordered visible columns + config?: ViewColumns + // JSON of host-specific filter state + extra?: string +} + +export interface EffectiveViewConfig { + viewletId?: string | null + filters: string + viewOptions?: ViewOptions + config?: ViewColumns + extra?: string +} + +export interface EffectiveViewConfigParams { + // Built-in defaults of the viewlet + defaults?: ViewConfigLayer + // Global per-viewlet column preference (ViewletPreference.config) + preference?: ViewColumns + // The active saved view + saved?: ViewConfigLayer + // Unsaved local edits of the active view + local?: ViewConfigLayer + // Viewlet that is actually displayed. When it differs from the layout the layers were authored + // for, their layout specific parts (columns, view options) are ignored + currentViewletId?: string | null +} + +export const EMPTY_FILTERS = '[]' + +// Id of the view that is shown for a project without any saved view +export const DEFAULT_VIEW_ID = 'default-view' + +function hasColumns (config: ViewColumns | undefined): config is ViewColumns { + return config !== undefined && config.length > 0 +} + +/** + * The single accessor for what a view displays. Precedence, strongest first: + * unsaved local edits, the saved view, the global ViewletPreference (columns only), the defaults. + * An empty column list means "not specified". + */ +export function getEffectiveViewConfig (params: EffectiveViewConfigParams): EffectiveViewConfig { + const { defaults, preference, saved, local, currentViewletId } = params + const viewletId = local?.viewletId ?? saved?.viewletId ?? defaults?.viewletId + const layoutMatches = currentViewletId === undefined || viewletId == null || viewletId === currentViewletId + const layouts = layoutMatches ? [local, saved] : [] + + let config: ViewColumns | undefined + let viewOptions: ViewOptions | undefined + for (const layer of layouts) { + if (config === undefined && hasColumns(layer?.config)) config = layer?.config + if (viewOptions === undefined && layer?.viewOptions !== undefined) viewOptions = layer.viewOptions + } + if (config === undefined && hasColumns(preference)) config = preference + config ??= defaults?.config + viewOptions ??= defaults?.viewOptions + + return { + viewletId, + filters: local?.filters ?? saved?.filters ?? defaults?.filters ?? EMPTY_FILTERS, + viewOptions, + config: hasColumns(config) ? config : undefined, + extra: local?.extra ?? saved?.extra ?? defaults?.extra + } +} + +function normalize (layer: ViewConfigLayer): Required> & ViewConfigLayer { + return { + viewletId: layer.viewletId ?? undefined, + filters: layer.filters === undefined || layer.filters === '' ? EMPTY_FILTERS : layer.filters, + viewOptions: layer.viewOptions, + config: hasColumns(layer.config) ? layer.config : undefined, + extra: layer.extra === undefined || layer.extra === '' || layer.extra === '[]' ? undefined : layer.extra + } +} + +/** + * A view has unsaved changes when its current state differs from the baseline it was applied or saved with. + */ +export function isViewDirty (baseline: ViewConfigLayer, current: ViewConfigLayer): boolean { + return !deepEqual(normalize(baseline), normalize(current)) +} + +function nameKey (name: string): string { + return name.trim().toLowerCase() +} + +/** + * Default name of a new view: "View N" with the smallest free N, starting from the number of existing views + 1 + */ +export function nextViewName (existing: readonly string[], prefix: string = 'View'): string { + const taken = new Set(existing.map(nameKey)) + let n = existing.length + 1 + while (taken.has(nameKey(`${prefix} ${n}`))) n++ + return `${prefix} ${n}` +} + +/** + * Name of a duplicated view: "X (copy)", then "X (copy 2)", "X (copy 3)", ... + */ +export function duplicateViewName (name: string, existing: readonly string[]): string { + const taken = new Set(existing.map(nameKey)) + const first = `${name} (copy)` + if (!taken.has(nameKey(first))) return first + let n = 2 + while (taken.has(nameKey(`${name} (copy ${n})`))) n++ + return `${name} (copy ${n})` +} + +export interface OrderedView { + _id: string + order?: number + createdOn?: number +} + +/** + * Tab order: by `order`, views without one go last, ties by creation time + */ +export function compareViews (a: OrderedView, b: OrderedView): number { + const ao = a.order ?? Number.POSITIVE_INFINITY + const bo = b.order ?? Number.POSITIVE_INFINITY + if (ao !== bo) return ao < bo ? -1 : 1 + return (a.createdOn ?? 0) - (b.createdOn ?? 0) +} + +export function sortViews(views: readonly T[]): T[] { + return [...views].sort(compareViews) +} + +/** + * Order value for a view appended after all the others + */ +export function nextOrder (views: readonly OrderedView[]): number { + let max = -1 + for (const v of views) { + if (v.order !== undefined && v.order > max) max = v.order + } + return max + 1 +} + +/** + * Order value for the view `id` after it is moved to index `toIndex` of the tab row. + * Only the moved view needs to be updated: its order is placed between its new neighbours. + * Returns undefined when nothing changes. + */ +export function orderForMove (views: readonly OrderedView[], id: string, toIndex: number): number | undefined { + const sorted = sortViews(views) + const from = sorted.findIndex((v) => v._id === id) + if (from === -1) return undefined + const target = Math.max(0, Math.min(toIndex, sorted.length - 1)) + if (target === from) return undefined + + const rest = sorted.filter((v) => v._id !== id) + const prev = rest[target - 1] + const next = rest[target] + const prevOrder = prev?.order ?? (prev !== undefined ? target - 1 : undefined) + const nextOrderValue = next?.order ?? (next !== undefined ? target : undefined) + + if (prevOrder !== undefined && nextOrderValue !== undefined) { + // Neighbours without a stored order cannot be compared reliably, fall back to their index + return prevOrder < nextOrderValue ? (prevOrder + nextOrderValue) / 2 : prevOrder + 1 + } + if (prevOrder !== undefined) return prevOrder + 1 + if (nextOrderValue !== undefined) return nextOrderValue - 1 + return 0 +} + +/** + * Safe parse of a stored Filter[] JSON; a corrupt value yields no filters instead of breaking the page + */ +export function parseStoredFilters (json: string | undefined): any[] { + if (json === undefined || json === '') return [] + try { + const parsed = JSON.parse(json) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} diff --git a/plugins/view-resources/src/viewOptions.ts b/plugins/view-resources/src/viewOptions.ts index 463a596bd9..01ede8db3d 100644 --- a/plugins/view-resources/src/viewOptions.ts +++ b/plugins/view-resources/src/viewOptions.ts @@ -19,6 +19,7 @@ import { type ToggleViewOption, type ViewOptionModel, type ViewOptions, + type ViewOptionsModel, type Viewlet, type ViewletDescriptor, type BuildModelKey @@ -34,6 +35,22 @@ export const defaultOptions: ViewOptions = { orderBy: ['modifiedBy', SortingOrder.Descending] } +/** + * Options a viewlet starts with when the user has not changed anything yet + */ +export function getViewletDefaultOptions (viewlet: Viewlet | undefined): ViewOptions { + const model: ViewOptionsModel | undefined = viewlet?.viewOptions + if (model == null) return defaultOptions + const res: ViewOptions = { + groupBy: [model.groupBy[0] ?? defaultOptions.groupBy[0]], + orderBy: model.orderBy?.[0] ?? defaultOptions.orderBy + } + for (const opt of model.other) { + res[opt.key] = opt.defaultValue + } + return res +} + export function isToggleType (viewOption: ViewOptionModel): viewOption is ToggleViewOption { return viewOption.type === 'toggle' } diff --git a/plugins/view/src/types.ts b/plugins/view/src/types.ts index a9e2649de0..df3bddde42 100644 --- a/plugins/view/src/types.ts +++ b/plugins/view/src/types.ts @@ -118,6 +118,18 @@ export interface FilteredView extends Doc { users: AccountUuid[] createdBy: PersonId attachedTo: string + + // Project saved views (Tracker, GitHub-Projects style). All fields are optional and additive, + // so filters saved before they existed keep working unchanged. + + // Space (project) the view is a tab of. Project views are shared by every member of the project + project?: Ref + // Ordered visible columns of the view's layout (overrides the global ViewletPreference) + config?: (BuildModelKey | string)[] + // Position of the tab, ascending + order?: number + // JSON of host-specific filter state that is not a Filter[] (e.g. custom field filters) + extra?: string } /** From 6d2067939e3ffee243b91edd1e484081686da08c Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:15:09 +0530 Subject: [PATCH 18/32] feat(tracker): GitHub-style filter grammar with query bar --- models/view/src/index.ts | 1 + plugins/tracker-resources/jest.config.js | 4 +- .../src/__mocks__/view-resources.js | 10 + .../src/__tests__/issueFilter.test.ts | 172 ++++++++++ .../src/components/issues/IssuesView.svelte | 140 +++++++- plugins/tracker-resources/src/issueFilter.ts | 245 ++++++++++++++ plugins/view-assets/lang/cs.json | 16 +- plugins/view-assets/lang/de.json | 16 +- plugins/view-assets/lang/en.json | 16 +- plugins/view-assets/lang/es.json | 16 +- plugins/view-assets/lang/fr.json | 16 +- plugins/view-assets/lang/it.json | 16 +- plugins/view-assets/lang/ja.json | 16 +- plugins/view-assets/lang/ko.json | 16 +- plugins/view-assets/lang/pl.json | 16 +- plugins/view-assets/lang/pt-br.json | 16 +- plugins/view-assets/lang/pt.json | 16 +- plugins/view-assets/lang/ru.json | 16 +- plugins/view-assets/lang/tr.json | 16 +- plugins/view-assets/lang/zh.json | 16 +- .../src/__tests__/savedViews.test.ts | 24 +- .../components/filter/FilterQueryBar.svelte | 298 ++++++++++++++++++ .../src/components/view/SavedViewBar.svelte | 18 +- .../filter/grammar/__tests__/compile.test.ts | 248 +++++++++++++++ .../filter/grammar/__tests__/evaluate.test.ts | 193 ++++++++++++ .../src/filter/grammar/__tests__/fixtures.ts | 159 ++++++++++ .../filter/grammar/__tests__/parser.test.ts | 199 ++++++++++++ .../filter/grammar/__tests__/suggest.test.ts | 104 ++++++ .../grammar/__tests__/tokenizer.test.ts | 108 +++++++ .../filter/grammar/__tests__/values.test.ts | 127 ++++++++ .../src/filter/grammar/compile.d.ts | 52 +++ .../src/filter/grammar/compile.d.ts.map | 1 + .../src/filter/grammar/compile.ts | 270 ++++++++++++++++ .../src/filter/grammar/evaluate.d.ts | 49 +++ .../src/filter/grammar/evaluate.d.ts.map | 1 + .../src/filter/grammar/evaluate.ts | 256 +++++++++++++++ .../src/filter/grammar/index.d.ts | 8 + .../src/filter/grammar/index.d.ts.map | 1 + .../src/filter/grammar/index.ts | 12 + .../src/filter/grammar/parser.d.ts | 35 ++ .../src/filter/grammar/parser.d.ts.map | 1 + .../src/filter/grammar/parser.ts | 294 +++++++++++++++++ .../src/filter/grammar/suggest.d.ts | 44 +++ .../src/filter/grammar/suggest.d.ts.map | 1 + .../src/filter/grammar/suggest.ts | 180 +++++++++++ .../src/filter/grammar/tokenizer.d.ts | 11 + .../src/filter/grammar/tokenizer.d.ts.map | 1 + .../src/filter/grammar/tokenizer.ts | 120 +++++++ .../src/filter/grammar/types.d.ts | 202 ++++++++++++ .../src/filter/grammar/types.d.ts.map | 1 + .../src/filter/grammar/types.ts | 213 +++++++++++++ .../src/filter/grammar/values.d.ts | 44 +++ .../src/filter/grammar/values.d.ts.map | 1 + .../src/filter/grammar/values.ts | 155 +++++++++ plugins/view-resources/src/index.ts | 3 + plugins/view-resources/src/plugin.ts | 17 +- plugins/view-resources/src/savedViews.ts | 10 +- plugins/view/src/types.ts | 3 + 58 files changed, 4223 insertions(+), 37 deletions(-) create mode 100644 plugins/tracker-resources/src/__mocks__/view-resources.js create mode 100644 plugins/tracker-resources/src/__tests__/issueFilter.test.ts create mode 100644 plugins/tracker-resources/src/issueFilter.ts create mode 100644 plugins/view-resources/src/components/filter/FilterQueryBar.svelte create mode 100644 plugins/view-resources/src/filter/grammar/__tests__/compile.test.ts create mode 100644 plugins/view-resources/src/filter/grammar/__tests__/evaluate.test.ts create mode 100644 plugins/view-resources/src/filter/grammar/__tests__/fixtures.ts create mode 100644 plugins/view-resources/src/filter/grammar/__tests__/parser.test.ts create mode 100644 plugins/view-resources/src/filter/grammar/__tests__/suggest.test.ts create mode 100644 plugins/view-resources/src/filter/grammar/__tests__/tokenizer.test.ts create mode 100644 plugins/view-resources/src/filter/grammar/__tests__/values.test.ts create mode 100644 plugins/view-resources/src/filter/grammar/compile.d.ts create mode 100644 plugins/view-resources/src/filter/grammar/compile.d.ts.map create mode 100644 plugins/view-resources/src/filter/grammar/compile.ts create mode 100644 plugins/view-resources/src/filter/grammar/evaluate.d.ts create mode 100644 plugins/view-resources/src/filter/grammar/evaluate.d.ts.map create mode 100644 plugins/view-resources/src/filter/grammar/evaluate.ts create mode 100644 plugins/view-resources/src/filter/grammar/index.d.ts create mode 100644 plugins/view-resources/src/filter/grammar/index.d.ts.map create mode 100644 plugins/view-resources/src/filter/grammar/index.ts create mode 100644 plugins/view-resources/src/filter/grammar/parser.d.ts create mode 100644 plugins/view-resources/src/filter/grammar/parser.d.ts.map create mode 100644 plugins/view-resources/src/filter/grammar/parser.ts create mode 100644 plugins/view-resources/src/filter/grammar/suggest.d.ts create mode 100644 plugins/view-resources/src/filter/grammar/suggest.d.ts.map create mode 100644 plugins/view-resources/src/filter/grammar/suggest.ts create mode 100644 plugins/view-resources/src/filter/grammar/tokenizer.d.ts create mode 100644 plugins/view-resources/src/filter/grammar/tokenizer.d.ts.map create mode 100644 plugins/view-resources/src/filter/grammar/tokenizer.ts create mode 100644 plugins/view-resources/src/filter/grammar/types.d.ts create mode 100644 plugins/view-resources/src/filter/grammar/types.d.ts.map create mode 100644 plugins/view-resources/src/filter/grammar/types.ts create mode 100644 plugins/view-resources/src/filter/grammar/values.d.ts create mode 100644 plugins/view-resources/src/filter/grammar/values.d.ts.map create mode 100644 plugins/view-resources/src/filter/grammar/values.ts diff --git a/models/view/src/index.ts b/models/view/src/index.ts index 3e7f2ab0df..c5e6ef160d 100644 --- a/models/view/src/index.ts +++ b/models/view/src/index.ts @@ -137,6 +137,7 @@ export class TFilteredView extends TDoc implements FilteredView { config?: (BuildModelKey | string)[] order?: number extra?: string + filterQuery?: string } @Model(view.class.FilterMode, core.class.Doc, DOMAIN_MODEL) diff --git a/plugins/tracker-resources/jest.config.js b/plugins/tracker-resources/jest.config.js index 4b4c455c23..ea16d50280 100644 --- a/plugins/tracker-resources/jest.config.js +++ b/plugins/tracker-resources/jest.config.js @@ -7,6 +7,8 @@ module.exports = { // tests that only need constants (PaletteColorIndexes, etc.) don't pull // in the svelte/store transitive import from the main index. // Tests that need richer UI stubs override this via their own jest.mock(). - '^@hcengineering/ui$': '/node_modules/@hcengineering/ui/src/colors.ts' + '^@hcengineering/ui$': '/node_modules/@hcengineering/ui/src/colors.ts', + // The filter grammar is a pure module of view-resources; the package index pulls in svelte components + '^@hcengineering/view-resources$': '/src/__mocks__/view-resources.js' } } diff --git a/plugins/tracker-resources/src/__mocks__/view-resources.js b/plugins/tracker-resources/src/__mocks__/view-resources.js new file mode 100644 index 0000000000..3cba1224f5 --- /dev/null +++ b/plugins/tracker-resources/src/__mocks__/view-resources.js @@ -0,0 +1,10 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +const path = require('path') + +// Jest stand-in for @hcengineering/view-resources: only the pure filter grammar, without the svelte components. +// The path is built at run time so that the type checkers do not pull view-resources sources into this package. +module.exports = { filterGrammar: require(path.resolve(__dirname, '../../../view-resources/src/filter/grammar')) } diff --git a/plugins/tracker-resources/src/__tests__/issueFilter.test.ts b/plugins/tracker-resources/src/__tests__/issueFilter.test.ts new file mode 100644 index 0000000000..98fede7585 --- /dev/null +++ b/plugins/tracker-resources/src/__tests__/issueFilter.test.ts @@ -0,0 +1,172 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { ProjectFieldType, type ProjectField } from '@hcengineering/tracker' +import { filterGrammar } from '@hcengineering/view-resources' +import { buildIssueFilterSchema, customFilterToQuery, fieldFilterName } from '../issueFilter' +import { buildFiltersPredicate, endOfDay, startOfDay, type CustomFieldFilter } from '../projectFields/query' +import { buildRegistry } from '../projectFields/registry' + +function field (key: string, label: string, type: ProjectFieldType, options?: Array<[string, string]>): ProjectField { + return { + _id: key, + key, + label, + type, + position: 0, + options: options?.map(([value, l]) => ({ value, label: l })) + } as unknown as ProjectField +} + +const fields = [ + field('storyPoints', 'Story points', ProjectFieldType.Number), + field('notes', 'Notes', ProjectFieldType.Text), + field('target', 'Target date', ProjectFieldType.Date), + field('size', 'Size', ProjectFieldType.SingleSelect, [ + ['s', 'Small'], + ['l', 'Extra large'] + ]), + field('area', 'Area', ProjectFieldType.MultiSelect, [ + ['fe', 'Frontend'], + ['be', 'Backend'] + ]), + field('status', 'Status', ProjectFieldType.Text) +] + +const { compileFilter, evaluate, parseFilter } = filterGrammar + +const schema = buildIssueFilterSchema({ + statuses: [{ id: 's1', name: 'Todo' }], + priorities: [{ id: 1, name: 'Urgent' }], + assignees: [{ id: 'p1', name: 'Alice' }], + components: [], + milestones: [], + labels: [{ id: 'l1', name: 'bug' }], + labelRefs: [{ issue: 'i1', label: 'l1' }], + customFields: fields, + noParentId: 'no-parent' +}) + +describe('fieldFilterName', () => { + it('lowercases and hyphenates', () => { + expect(fieldFilterName('Story Points', 'storyPoints')).toBe('story-points') + }) + + it('supports non-latin labels', () => { + expect(fieldFilterName('Оценка работ', 'ocenkaRabot')).toBe('оценка-работ') + }) + + it('falls back to the key for labels that are not valid names', () => { + expect(fieldFilterName('2 weeks', 'f2Weeks')).toBe('f2weeks') + expect(fieldFilterName('???', 'fieldX')).toBe('fieldx') + }) +}) + +describe('buildIssueFilterSchema', () => { + it('contains the built-in fields and the custom fields', () => { + const names = schema.map((f) => f.name) + expect(names).toEqual(expect.arrayContaining(['title', 'status', 'priority', 'assignee', 'label', 'parent-issue'])) + expect(names).toEqual(expect.arrayContaining(['story-points', 'notes', 'target-date', 'size', 'area'])) + }) + + it('keeps built-in fields when a custom field has the same name', () => { + expect(schema.filter((f) => f.name === 'status')).toHaveLength(1) + expect(schema.find((f) => f.name === 'status')?.source).toBe('attribute') + }) + + it('maps custom field types and options', () => { + const size = schema.find((f) => f.name === 'size') + expect(size).toMatchObject({ type: 'select', source: 'custom', key: 'size' }) + expect(size?.options).toEqual([ + { id: 's', name: 'Small' }, + { id: 'l', name: 'Extra large' } + ]) + expect(schema.find((f) => f.name === 'area')?.type).toBe('multi') + expect(schema.find((f) => f.name === 'target-date')?.type).toBe('date') + }) + + it('resolves labels through the tag references', () => { + const label = schema.find((f) => f.name === 'label') + expect(label?.resolveDocIds?.(['l1'])).toEqual(['i1']) + expect(label?.read?.({ _id: 'i1' })).toEqual(['l1']) + expect(label?.read?.({ _id: 'other' })).toEqual([]) + }) + + it('parses filter strings against the schema', () => { + expect(parseFilter('story-points:>3 target-date:@today size:"extra large" label:bug', schema).ok).toBe(true) + }) +}) + +describe('customFilterToQuery', () => { + const registry = buildRegistry(fields) + const rule = (fieldKey: string, operator: any, value?: any): CustomFieldFilter => ({ + id: fieldKey + operator, + fieldKey, + operator, + value + }) + + it('writes each operator in grammar syntax', () => { + const q = (r: CustomFieldFilter): string => customFilterToQuery([r], registry.byKey) + expect(q(rule('storyPoints', 'eq', 5))).toBe('story-points:5') + expect(q(rule('storyPoints', 'gt', 5))).toBe('story-points:>5') + expect(q(rule('storyPoints', 'lte', 5))).toBe('story-points:<=5') + expect(q(rule('storyPoints', 'between', { from: 1, to: 3 }))).toBe('story-points:1..3') + expect(q(rule('storyPoints', 'between', { from: 1 }))).toBe('story-points:1..*') + expect(q(rule('notes', 'contains', ' hello world '))).toBe('notes:"hello world"') + expect(q(rule('size', 'anyOf', ['s', 'l']))).toBe('size:Small,"Extra large"') + expect(q(rule('notes', 'isEmpty'))).toBe('no:notes') + expect(q(rule('notes', 'isNotEmpty'))).toBe('has:notes') + }) + + it('writes dates as calendar days', () => { + const d = new Date(2026, 0, 5, 15).getTime() + expect(customFilterToQuery([rule('target', 'before', d)], registry.byKey)).toBe('target-date:<2026-01-05') + expect(customFilterToQuery([rule('target', 'after', d)], registry.byKey)).toBe('target-date:>2026-01-05') + expect(customFilterToQuery([rule('target', 'between', { from: d, to: d })], registry.byKey)).toBe( + 'target-date:2026-01-05..2026-01-05' + ) + }) + + it('skips incomplete rules and unknown fields and joins with AND', () => { + const q = customFilterToQuery( + [rule('storyPoints', 'gt'), rule('gone', 'eq', 1), rule('storyPoints', 'eq', 2), rule('notes', 'isEmpty')], + registry.byKey + ) + expect(q).toBe('story-points:2 no:notes') + }) + + it('selects the same issues as the rules did', () => { + const day = new Date(2026, 5, 10, 9).getTime() + const issues: any[] = [ + { _id: 'a', customFields: { storyPoints: 3, notes: 'Hello World', target: day, size: 's', area: ['fe'] } }, + { _id: 'b', customFields: { storyPoints: 8, notes: 'x', target: endOfDay(day) + 1, size: 'l', area: [] } }, + { _id: 'c', customFields: { storyPoints: 5, target: startOfDay(day) - 1 } }, + { _id: 'd' } + ] + const ruleSets: CustomFieldFilter[][] = [ + [rule('storyPoints', 'gt', 3)], + [rule('storyPoints', 'between', { from: 3, to: 5 })], + [rule('notes', 'contains', 'hello')], + [rule('size', 'anyOf', ['s', 'l'])], + [rule('area', 'anyOf', ['fe'])], + [rule('area', 'isEmpty')], + [rule('target', 'before', day)], + [rule('target', 'after', day)], + [rule('target', 'between', { from: day, to: day })], + [rule('storyPoints', 'gte', 5), rule('notes', 'isNotEmpty')] + ] + const ctx = { now: day } + for (const rules of ruleSets) { + const legacy = buildFiltersPredicate(registry.byKey, rules) + const text = customFilterToQuery(rules, registry.byKey) + const parsed = compileFilter(text, schema, ctx) + if (!parsed.ok) throw new Error(`${text}: ${parsed.error.message}`) + for (const issue of issues) { + expect([text, issue._id, evaluate(parsed.value.ast, issue, ctx)]).toEqual([text, issue._id, legacy(issue)]) + } + } + }) +}) diff --git a/plugins/tracker-resources/src/components/issues/IssuesView.svelte b/plugins/tracker-resources/src/components/issues/IssuesView.svelte index 76a6146c08..3918e69317 100644 --- a/plugins/tracker-resources/src/components/issues/IssuesView.svelte +++ b/plugins/tracker-resources/src/components/issues/IssuesView.svelte @@ -1,7 +1,10 @@ + +
+
+ + { + focused = true + updateSuggestions() + }} + on:blur={onBlur} + /> + {#if draft === ''} + + {/if} + {#if draft !== ''} +
+ {#if showError && error !== undefined} + + {/if} +
+ + diff --git a/plugins/view-resources/src/components/view/SavedViewBar.svelte b/plugins/view-resources/src/components/view/SavedViewBar.svelte index bc7e09e021..22c127446a 100644 --- a/plugins/view-resources/src/components/view/SavedViewBar.svelte +++ b/plugins/view-resources/src/components/view/SavedViewBar.svelte @@ -56,6 +56,8 @@ export let layouts: Array> // Host specific filter state that is saved with the view (e.g. custom field filters) export let extra: Writable | undefined = undefined + // GitHub-style filter string of the view (bind it); saved with the view and part of its unsaved-changes state + export let filterQuery: string = '' // Columns of the active view; undefined until it is known export let config: ViewColumns | undefined = undefined @@ -161,7 +163,8 @@ filters: doc?.filters, viewOptions: doc?.viewOptions, config: doc?.config, - extra: doc?.extra + extra: doc?.extra, + filterQuery: doc?.filterQuery } } @@ -179,6 +182,7 @@ if (layer.viewOptions !== undefined && target !== undefined) setViewOptions(target, clone(layer.viewOptions)) setFilters(parseStoredFilters(layer.filters)) extraStore.set(parseStoredFilters(layer.extra)) + filterQuery = layer.filterQuery ?? '' } function apply (tab: Tab): void { @@ -213,7 +217,8 @@ filters: JSON.stringify($filterStore), viewOptions: currentOptions as ViewOptions | undefined, config: effective.config, - extra: JSON.stringify($extraStore) + extra: JSON.stringify($extraStore), + filterQuery } satisfies ViewConfigLayer // The baseline is taken once the view is applied and the displayed layout has caught up @@ -242,7 +247,8 @@ viewletId: current.viewletId as Ref | undefined, sharable: true, config: clone(current.config) ?? [], - extra: current.extra + extra: current.extra, + filterQuery: current.filterQuery } } @@ -327,7 +333,8 @@ viewletId, sharable: true, config: [], - extra: '[]' + extra: '[]', + filterQuery: '' }, order ) @@ -352,7 +359,8 @@ viewletId: doc.viewletId, sharable: true, config: clone(doc.config) ?? [], - extra: doc.extra + extra: doc.extra, + filterQuery: doc.filterQuery } } diff --git a/plugins/view-resources/src/filter/grammar/__tests__/compile.test.ts b/plugins/view-resources/src/filter/grammar/__tests__/compile.test.ts new file mode 100644 index 0000000000..5ef85a41fe --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/__tests__/compile.test.ts @@ -0,0 +1,248 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { compileFilter, compileQuery, referencedProperties, splitServerClient } from '../compile' +import { parseFilter } from '../parser' +import type { Node } from '../types' +import { ctx, ISSUES, schema } from './fixtures' + +function ast (input: string): Node { + const res = parseFilter(input, schema) + if (!res.ok) throw new Error(`${input}: ${res.error.message}`) + return res.value +} + +// Minimal evaluator of the DocumentQuery subset the compiler emits +function like (pattern: string, value: unknown): boolean { + if (typeof value !== 'string') return false + const re = new RegExp(`^${pattern.split('%').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`, 'i') + return re.test(value) +} + +function matchSelector (sel: any, value: unknown): boolean { + if (typeof sel !== 'object' || sel === null) return (value ?? null) === sel + const v = value ?? null + for (const [op, arg] of Object.entries(sel)) { + switch (op) { + case '$in': + if (!arg.includes(v)) return false + break + case '$nin': + if (arg.includes(v)) return false + break + case '$ne': + if (v === arg) return false + break + case '$gt': + if (typeof v !== 'number' || !(v > arg)) return false + break + case '$gte': + if (typeof v !== 'number' || !(v >= arg)) return false + break + case '$lt': + if (typeof v !== 'number' || !(v < arg)) return false + break + case '$lte': + if (typeof v !== 'number' || !(v <= arg)) return false + break + case '$like': + if (!like(arg, v)) return false + break + default: + throw new Error(`unsupported operator ${op}`) + } + } + return true +} + +function matchQuery (query: Record, doc: any): boolean { + return Object.entries(query).every(([key, sel]) => matchSelector(sel, doc[key])) +} + +describe('splitServerClient', () => { + it('compiles an empty filter to an empty query', () => { + expect(splitServerClient(ast(''), ctx)).toEqual({ query: {}, residual: undefined }) + }) + + it('compiles select, user and priority fields to $in / $nin', () => { + expect(compileQuery(ast('status:Todo,Done'), ctx)).toEqual({ status: { $in: ['st-todo', 'st-done'] } }) + expect(compileQuery(ast('priority:urgent'), ctx)).toEqual({ priority: { $in: [1] } }) + expect(compileQuery(ast('assignee:@me'), ctx)).toEqual({ assignee: { $in: ['p-alice'] } }) + expect(compileQuery(ast('-status:Done'), ctx)).toEqual({ status: { $nin: ['st-done'] } }) + expect(compileQuery(ast('milestone:v1'), ctx)).toEqual({ milestone: { $in: ['m1'] } }) + }) + + it('selects nothing for an unknown option', () => { + expect(compileQuery(ast('status:zzz'), ctx)).toEqual({ status: { $in: [] } }) + }) + + it('compiles has: and no: to null checks', () => { + expect(compileQuery(ast('no:assignee'), ctx)).toEqual({ assignee: null }) + expect(compileQuery(ast('has:due'), ctx)).toEqual({ dueDate: { $ne: null } }) + expect(compileQuery(ast('no:parent-issue'), ctx)).toEqual({ attachedTo: 'no-parent' }) + expect(compileQuery(ast('has:parent-issue'), ctx)).toEqual({ attachedTo: { $ne: 'no-parent' } }) + }) + + it('compiles title text and field to $like', () => { + expect(compileQuery(ast('login'), ctx)).toEqual({ title: { $like: '%login%' } }) + expect(compileQuery(ast('title:"fix*bug"'), ctx)).toEqual({ title: { $like: '%fix%bug%' } }) + }) + + it('keeps LIKE metacharacters on the client', () => { + const res = splitServerClient(ast('100%'), ctx) + expect(res.query).toEqual({}) + expect(res.residual).toMatchObject({ type: 'text' }) + }) + + it('compiles numeric and date bounds', () => { + expect(compileQuery(ast('estimate:>5'), ctx)).toEqual({ estimation: { $gt: 5 } }) + expect(compileQuery(ast('estimate:2..5'), ctx)).toEqual({ estimation: { $gte: 2, $lte: 5 } }) + expect(compileQuery(ast('estimate:<=3'), ctx)).toEqual({ estimation: { $lte: 3 } }) + const date = compileQuery(ast('due:>=@today'), ctx) + expect(date).toEqual({ dueDate: { $gte: expect.any(Number) } }) + expect(compileQuery(ast('due:*..*'), ctx)).toEqual({ dueDate: { $ne: null } }) + }) + + it('merges two bounds on the same attribute', () => { + const q = compileQuery(ast('due:>@today-7d due:<@today+1d'), ctx) + expect(q).toEqual({ dueDate: { $gte: expect.any(Number), $lte: expect.any(Number) } }) + }) + + it('moves a colliding condition on the same attribute to the client', () => { + const res = splitServerClient(ast('status:Todo status:Done'), ctx) + expect(res.query).toEqual({ status: { $in: ['st-todo'] } }) + expect(res.residual).toMatchObject({ type: 'field', field: { name: 'status' } }) + }) + + it('compiles labels through the document id resolver', () => { + const q = compileQuery(ast('label:bug'), ctx) + expect(q?._id.$in).toHaveLength(2) + expect(compileQuery(ast('-label:bug'), ctx)?._id.$nin).toHaveLength(2) + }) + + it('compiles is: states', () => { + expect(compileQuery(ast('is:open'), ctx)).toEqual({ status: { $nin: ['st-done', 'st-canceled'] } }) + expect(compileQuery(ast('is:closed'), ctx)).toEqual({ status: { $in: ['st-done', 'st-canceled'] } }) + expect(compileQuery(ast('is:issue'), ctx)).toEqual({}) + expect(compileQuery(ast('is:sub-issue'), ctx)).toEqual({ attachedTo: { $ne: 'no-parent' } }) + }) + + it('merges OR of the same field into one selector', () => { + expect(compileQuery(ast('status:Todo OR status:Done'), ctx)).toEqual({ status: { $in: ['st-todo', 'st-done'] } }) + }) + + it('sends custom fields to the client', () => { + const res = splitServerClient(ast('status:Todo story-points:>3'), ctx) + expect(res.query).toEqual({ status: { $in: ['st-todo'] } }) + expect(res.residual).toMatchObject({ type: 'field', field: { name: 'story-points' } }) + expect(compileQuery(ast('story-points:>3'), ctx)).toBeUndefined() + }) + + it('keeps an OR across fields whole on the client', () => { + const res = splitServerClient(ast('priority:urgent OR status:done'), ctx) + expect(res.query).toEqual({}) + expect(res.residual?.type).toBe('or') + }) + + it('keeps an OR across server and client fields whole on the client', () => { + const res = splitServerClient(ast('status:done OR story-points:>3'), ctx) + expect(res.query).toEqual({}) + expect(res.residual?.type).toBe('or') + }) + + it('splits a top level AND into server and client parts', () => { + const res = splitServerClient(ast('assignee:@me (priority:urgent OR size:L) login'), ctx) + expect(res.query).toEqual({ assignee: { $in: ['p-alice'] }, title: { $like: '%login%' } }) + expect(res.residual?.type).toBe('or') + }) + + it('does not compile iterations on the server', () => { + expect(compileQuery(ast('iteration:@current'), ctx)).toBeUndefined() + }) +}) + +describe('server query + client residual equals full client evaluation', () => { + const filters = [ + '', + 'login', + 'status:Todo,Done', + '-status:Done', + 'priority:urgent,high', + 'assignee:@me', + '-assignee:@me', + 'no:assignee', + 'has:due', + 'label:bug', + '-label:bug', + 'label:bug label:ui', + 'is:open', + 'is:closed', + 'is:sub-issue', + 'no:parent-issue', + 'estimate:>5', + 'estimate:<=5', + 'estimate:1..8', + 'estimate:8..*', + 'due:@today', + 'due:<@today', + 'due:>@today-7d', + 'due:>@today-14d due:<@today+1d', + 'due:@today-10d..@today', + 'status:Todo status:Done', + 'story-points:>3', + 'status:progress,todo story-points:<5', + 'priority:urgent OR status:done', + 'status:done OR story-points:>3', + '(priority:urgent OR status:done) assignee:@me', + '-(status:done OR status:canceled)', + 'assignee:@me size:M', + 'iteration:@current OR label:bug', + 'bug status:"in progress"', + '100%', + 'title:*coverage -is:closed', + 'no:size OR has:notes' + ] + + for (const f of filters) { + it(`"${f}"`, () => { + const res = compileFilter(f, schema, ctx) + if (!res.ok) throw new Error(res.error.message) + const combined = ISSUES.filter((d) => matchQuery(res.value.query, d) && res.value.predicate(d)).map((d) => d._id) + const full = ISSUES.filter((d) => res.value.matches(d)).map((d) => d._id) + expect(combined).toEqual(full) + }) + } +}) + +describe('compileFilter', () => { + it('returns typed errors instead of throwing', () => { + const res = compileFilter('nope:1', schema, ctx) + expect(res.ok).toBe(false) + if (!res.ok) expect(res.error).toMatchObject({ code: 'unknownField', pos: 0 }) + }) + + it('exposes whether the query is exact', () => { + const exact = compileFilter('status:Todo', schema, ctx) + expect(exact.ok && exact.value.residual).toBeUndefined() + const mixed = compileFilter('status:Todo story-points:>1', schema, ctx) + expect(mixed.ok && mixed.value.residual).toBeDefined() + }) +}) + +describe('reserved keys', () => { + it('leaves conditions on reserved attributes to the client', () => { + const res = splitServerClient(ast('status:Todo priority:urgent'), ctx, new Set(['status'])) + expect(res.query).toEqual({ priority: { $in: [1] } }) + expect(res.residual).toMatchObject({ type: 'field', field: { name: 'status' } }) + }) +}) + +describe('referencedProperties', () => { + it('lists what the client has to load', () => { + expect(referencedProperties(undefined)).toEqual(['_id']) + const props = referencedProperties(ast('login (priority:urgent OR story-points:>1) is:open has:parent-issue')) + expect(props).toEqual(expect.arrayContaining(['_id', 'title', 'priority', 'customFields', 'status', 'attachedTo'])) + }) +}) diff --git a/plugins/view-resources/src/filter/grammar/__tests__/evaluate.test.ts b/plugins/view-resources/src/filter/grammar/__tests__/evaluate.test.ts new file mode 100644 index 0000000000..80d9f62a51 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/__tests__/evaluate.test.ts @@ -0,0 +1,193 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { evaluate } from '../evaluate' +import { parseFilter } from '../parser' +import { ctx, DAY, ISSUES, NOW, schema } from './fixtures' + +function titles (input: string): string[] { + const res = parseFilter(input, schema) + if (!res.ok) throw new Error(`${input}: ${res.error.message}`) + return ISSUES.filter((d) => evaluate(res.value, d, ctx)).map((d) => d.title) +} + +describe('evaluate: built-in fields', () => { + it('matches everything for an empty filter', () => { + expect(titles('')).toHaveLength(ISSUES.length) + }) + + it('matches free text in the title, case-insensitively', () => { + expect(titles('LOGIN')).toEqual(['Fix login bug']) + expect(titles('"login bug"')).toEqual(['Fix login bug']) + expect(titles('fix bug')).toEqual(['Fix login bug']) + expect(titles('title:"dark theme"')).toEqual(['Add dark theme']) + expect(titles('title:*coverage')).toEqual(['Refactor API 100% coverage']) + }) + + it('matches status by label with OR and wildcards', () => { + expect(titles('status:Done')).toEqual(['Release notes']) + expect(titles('status:todo,done')).toEqual(['Add dark theme', 'Release notes']) + expect(titles('status:"In Progress"')).toEqual(['Fix login bug', 'Plain issue']) + expect(titles('status:in*')).toEqual(['Fix login bug', 'Plain issue']) + expect(titles('status:nothing')).toEqual([]) + }) + + it('negates a field', () => { + expect(titles('-status:Done,Canceled,Todo')).toEqual(['Fix login bug', 'Plain issue']) + }) + + it('matches priority, assignee and @me', () => { + expect(titles('priority:Urgent')).toEqual(['Fix login bug']) + expect(titles('priority:urgent,high')).toEqual(['Fix login bug', 'Refactor API 100% coverage']) + expect(titles('assignee:@me')).toEqual(['Fix login bug', 'Release notes']) + expect(titles('assignee:alice')).toEqual(['Fix login bug', 'Release notes']) + expect(titles('assignee:"bob smith"')).toEqual(['Add dark theme', 'Plain issue']) + expect(titles('-assignee:@me')).toEqual(['Add dark theme', 'Refactor API 100% coverage', 'Plain issue']) + }) + + it('matches @me to nothing without a current user', () => { + const res = parseFilter('assignee:@me', schema) + if (!res.ok) throw new Error() + expect(ISSUES.some((d) => evaluate(res.value, d, { ...ctx, me: undefined }))).toBe(false) + }) + + it('matches labels with any-of and repeated fields as AND', () => { + expect(titles('label:bug')).toEqual(['Fix login bug', 'Refactor API 100% coverage']) + expect(titles('label:bug,ui')).toEqual(['Fix login bug', 'Add dark theme', 'Refactor API 100% coverage']) + expect(titles('label:bug label:ui')).toEqual(['Refactor API 100% coverage']) + expect(titles('label:"good first issue"')).toEqual(['Add dark theme']) + expect(titles('-label:bug')).toEqual(['Add dark theme', 'Release notes', 'Plain issue']) + }) + + it('matches milestone', () => { + expect(titles('milestone:v1')).toEqual(['Fix login bug']) + expect(titles('no:milestone')).toEqual(['Release notes', 'Refactor API 100% coverage', 'Plain issue']) + }) + + it('supports has: and no:', () => { + expect(titles('has:assignee')).toEqual(['Fix login bug', 'Add dark theme', 'Release notes', 'Plain issue']) + expect(titles('no:assignee')).toEqual(['Refactor API 100% coverage']) + expect(titles('no:label')).toEqual(['Release notes', 'Plain issue']) + expect(titles('has:label')).toHaveLength(3) + expect(titles('has:parent-issue')).toEqual(['Refactor API 100% coverage']) + expect(titles('no:parent-issue')).toHaveLength(4) + expect(titles('no:assignee,due')).toEqual(['Refactor API 100% coverage', 'Plain issue']) + }) + + it('supports is:open and is:closed', () => { + expect(titles('is:open')).toEqual(['Fix login bug', 'Add dark theme', 'Plain issue']) + expect(titles('is:closed')).toEqual(['Release notes', 'Refactor API 100% coverage']) + expect(titles('is:issue')).toHaveLength(5) + expect(titles('is:sub-issue')).toEqual(['Refactor API 100% coverage']) + expect(titles('-is:closed')).toHaveLength(3) + }) +}) + +describe('evaluate: numbers', () => { + it('compares with exact, strict and inclusive operators', () => { + expect(titles('estimate:5')).toEqual(['Fix login bug']) + expect(titles('estimate:>5')).toEqual(['Add dark theme', 'Refactor API 100% coverage']) + expect(titles('estimate:>=5')).toHaveLength(3) + expect(titles('estimate:<5')).toEqual(['Release notes', 'Plain issue']) + expect(titles('estimate:<=5')).toHaveLength(3) + }) + + it('supports inclusive ranges and wildcards', () => { + expect(titles('estimate:1..5')).toEqual(['Fix login bug', 'Release notes']) + expect(titles('estimate:8..*')).toEqual(['Add dark theme', 'Refactor API 100% coverage']) + expect(titles('estimate:*..1')).toEqual(['Release notes', 'Plain issue']) + }) + + it('does not match a missing value', () => { + expect(titles('story-points:<100')).toEqual(['Fix login bug', 'Add dark theme', 'Release notes', 'Refactor API 100% coverage']) + }) +}) + +describe('evaluate: dates', () => { + it('matches a whole day for equality', () => { + expect(titles('due:@today')).toEqual(['Fix login bug']) + expect(titles(`due:${new Date(NOW + 20 * DAY).getFullYear()}-${String(new Date(NOW + 20 * DAY).getMonth() + 1).padStart(2, '0')}-${String(new Date(NOW + 20 * DAY).getDate()).padStart(2, '0')}`)).toEqual(['Release notes']) + }) + + it('treats comparisons on whole days (Phase 1 semantics)', () => { + expect(titles('due:<@today')).toEqual(['Add dark theme']) + expect(titles('due:<=@today')).toEqual(['Fix login bug', 'Add dark theme']) + expect(titles('due:>@today')).toEqual(['Release notes']) + expect(titles('due:>=@today')).toEqual(['Fix login bug', 'Release notes']) + }) + + it('supports relative offsets', () => { + expect(titles('due:>@today-7d')).toEqual(['Fix login bug', 'Release notes']) + expect(titles('due:>@today-14d due:<@today+1d')).toEqual(['Fix login bug', 'Add dark theme']) + }) + + it('includes both end days of a range', () => { + expect(titles('due:@today-10d..@today')).toEqual(['Fix login bug', 'Add dark theme']) + expect(titles('due:@today..*')).toEqual(['Fix login bug', 'Release notes']) + expect(titles('due:*..@today-10d')).toEqual(['Add dark theme']) + }) + + it('applies to custom date fields', () => { + expect(titles('target:>@today')).toEqual(['Fix login bug']) + expect(titles('no:target')).toHaveLength(4) + }) +}) + +describe('evaluate: custom fields', () => { + it('matches numbers', () => { + expect(titles('story-points:>=8')).toEqual(['Add dark theme', 'Refactor API 100% coverage']) + expect(titles('story-points:1..3')).toEqual(['Fix login bug', 'Release notes']) + }) + + it('matches text by substring', () => { + expect(titles('notes:qa')).toEqual(['Fix login bug']) + expect(titles('has:notes')).toEqual(['Fix login bug']) + }) + + it('matches single select by label', () => { + expect(titles('size:M')).toEqual(['Fix login bug']) + expect(titles('size:s,l')).toEqual(['Add dark theme', 'Release notes']) + expect(titles('-size:M')).toHaveLength(4) + expect(titles('no:size')).toEqual(['Refactor API 100% coverage', 'Plain issue']) + }) + + it('matches multi select any-of and treats an empty list as empty', () => { + expect(titles('area:frontend')).toEqual(['Fix login bug', 'Add dark theme']) + expect(titles('area:backend,frontend')).toEqual(['Fix login bug', 'Add dark theme']) + expect(titles('no:area')).toEqual(['Release notes', 'Refactor API 100% coverage', 'Plain issue']) + }) + + it('matches iterations by keyword, arithmetic and title', () => { + expect(titles('iteration:@current')).toEqual(['Fix login bug']) + expect(titles('iteration:@next')).toEqual(['Add dark theme']) + expect(titles('iteration:@previous')).toEqual(['Release notes']) + expect(titles('iteration:@current+1')).toEqual(['Add dark theme']) + expect(titles('iteration:@previous,@next')).toEqual(['Add dark theme', 'Release notes']) + expect(titles('iteration:"Sprint 3"')).toEqual(['Add dark theme']) + expect(titles('iteration:@current..@next')).toEqual(['Fix login bug', 'Add dark theme']) + expect(titles('iteration:>@current')).toEqual(['Add dark theme']) + expect(titles('iteration:<=@current')).toEqual(['Fix login bug', 'Release notes']) + expect(titles('iteration:@current+5')).toEqual([]) + }) +}) + +describe('evaluate: boolean structure', () => { + it('supports OR across different fields', () => { + expect(titles('priority:urgent OR status:done')).toEqual(['Fix login bug', 'Release notes']) + }) + + it('supports parentheses and implicit AND', () => { + expect(titles('(priority:urgent OR status:done) assignee:@me')).toEqual(['Fix login bug', 'Release notes']) + expect(titles('(priority:urgent OR status:done) label:bug')).toEqual(['Fix login bug']) + }) + + it('supports negated groups', () => { + expect(titles('-(status:done OR status:canceled)')).toEqual(['Fix login bug', 'Add dark theme', 'Plain issue']) + }) + + it('combines free text with fields', () => { + expect(titles('bug status:"in progress"')).toEqual(['Fix login bug']) + }) +}) diff --git a/plugins/view-resources/src/filter/grammar/__tests__/fixtures.ts b/plugins/view-resources/src/filter/grammar/__tests__/fixtures.ts new file mode 100644 index 0000000000..e99e2ea606 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/__tests__/fixtures.ts @@ -0,0 +1,159 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { FieldSpec, FilterContext, IterationInfo } from '../types' + +export const DAY = 24 * 60 * 60 * 1000 +// Wednesday, local noon +export const NOW = new Date(2026, 5, 17, 12, 0, 0).getTime() + +export const ITERATIONS: IterationInfo[] = [ + { id: 'it1', title: 'Sprint 1', start: new Date(2026, 5, 1).getTime(), end: new Date(2026, 5, 14, 23, 59, 59, 999).getTime() }, + { id: 'it2', title: 'Sprint 2', start: new Date(2026, 5, 15).getTime(), end: new Date(2026, 5, 28, 23, 59, 59, 999).getTime() }, + { id: 'it3', title: 'Sprint 3', start: new Date(2026, 5, 29).getTime(), end: new Date(2026, 6, 12, 23, 59, 59, 999).getTime() }, + { id: 'it4', title: 'Sprint 4', start: new Date(2026, 6, 13).getTime(), end: new Date(2026, 6, 26, 23, 59, 59, 999).getTime() } +] + +export const schema: FieldSpec[] = [ + { name: 'title', label: 'Title', type: 'text', source: 'attribute', key: 'title' }, + { + name: 'status', + label: 'Status', + type: 'select', + source: 'attribute', + key: 'status', + options: [ + { id: 'st-todo', name: 'Todo' }, + { id: 'st-progress', name: 'In Progress' }, + { id: 'st-done', name: 'Done' }, + { id: 'st-canceled', name: 'Canceled' } + ] + }, + { + name: 'priority', + label: 'Priority', + type: 'select', + source: 'attribute', + key: 'priority', + options: [ + { id: 0, name: 'No priority' }, + { id: 1, name: 'Urgent' }, + { id: 2, name: 'High' }, + { id: 3, name: 'Medium' }, + { id: 4, name: 'Low' } + ] + }, + { + name: 'assignee', + label: 'Assignee', + type: 'user', + source: 'attribute', + key: 'assignee', + options: [ + { id: 'p-alice', name: 'Alice' }, + { id: 'p-bob', name: 'Bob Smith' } + ] + }, + { + name: 'milestone', + label: 'Milestone', + type: 'select', + source: 'attribute', + key: 'milestone', + options: [ + { id: 'm1', name: 'v1' }, + { id: 'm2', name: 'v2' } + ] + }, + { name: 'due', label: 'Due date', type: 'date', source: 'attribute', key: 'dueDate' }, + { name: 'estimate', label: 'Estimate', type: 'number', source: 'attribute', key: 'estimation' }, + { + name: 'label', + label: 'Label', + type: 'multi', + source: 'attribute', + key: 'labels', + options: [ + { id: 'l-bug', name: 'bug' }, + { id: 'l-ui', name: 'ui' }, + { id: 'l-gfi', name: 'good first issue' } + ], + read: (doc) => doc.labelIds, + resolveDocIds: (optionIds) => LABELLED.filter((d) => d.labelIds.some((l: string) => optionIds.includes(l))).map((d) => d._id) + }, + { + name: 'parent-issue', + label: 'Parent issue', + type: 'presence', + source: 'attribute', + key: 'attachedTo', + read: (doc) => (doc.attachedTo === 'no-parent' ? undefined : doc.attachedTo), + presenceQuery: (present) => ({ attachedTo: present ? { $ne: 'no-parent' } : 'no-parent' }) + }, + { name: 'story-points', label: 'Story points', type: 'number', source: 'custom', key: 'storyPoints' }, + { name: 'notes', label: 'Notes', type: 'text', source: 'custom', key: 'notes' }, + { name: 'target', label: 'Target', type: 'date', source: 'custom', key: 'target' }, + { + name: 'size', + label: 'Size', + type: 'select', + source: 'custom', + key: 'size', + options: [ + { id: 'o-s', name: 'S' }, + { id: 'o-m', name: 'M' }, + { id: 'o-l', name: 'L' } + ] + }, + { + name: 'area', + label: 'Area', + type: 'multi', + source: 'custom', + key: 'area', + options: [ + { id: 'o-fe', name: 'Frontend' }, + { id: 'o-be', name: 'Backend' } + ] + }, + { name: 'iteration', label: 'Iteration', type: 'iteration', source: 'custom', key: 'iter' } +] + +export const ctx: FilterContext = { + now: NOW, + me: 'p-alice', + iterations: () => ITERATIONS, + closedStatuses: new Set(['st-done', 'st-canceled']), + noParentId: 'no-parent' +} + +let n = 0 +export function issue (props: Record): any { + n++ + return { + _id: `i${n}`, + title: 'Untitled', + status: 'st-todo', + priority: 0, + assignee: null, + milestone: null, + dueDate: null, + estimation: 0, + labelIds: [], + attachedTo: 'no-parent', + ...props + } +} + +export const ISSUES: any[] = [ + issue({ title: 'Fix login bug', status: 'st-progress', priority: 1, assignee: 'p-alice', labelIds: ['l-bug'], dueDate: NOW, estimation: 5, milestone: 'm1', customFields: { storyPoints: 3, size: 'o-m', notes: 'Needs QA', area: ['o-fe'], target: NOW + 3 * DAY, iter: 'it2' } }), + issue({ title: 'Add dark theme', status: 'st-todo', priority: 3, assignee: 'p-bob', labelIds: ['l-ui', 'l-gfi'], dueDate: NOW - 10 * DAY, estimation: 8, milestone: 'm2', customFields: { storyPoints: 8, size: 'o-l', area: ['o-fe', 'o-be'], iter: 'it3' } }), + issue({ title: 'Release notes', status: 'st-done', priority: 4, assignee: 'p-alice', labelIds: [], dueDate: NOW + 20 * DAY, estimation: 1, customFields: { storyPoints: 1, size: 'o-s', iter: 'it1' } }), + issue({ title: 'Refactor API 100% coverage', status: 'st-canceled', priority: 2, labelIds: ['l-bug', 'l-ui'], attachedTo: 'i1', estimation: 13, customFields: { storyPoints: 13, area: [] } }), + issue({ title: 'Plain issue', status: 'st-progress', priority: 0, assignee: 'p-bob' }) +] + +// Referenced by the label field before it is initialised +export const LABELLED = ISSUES diff --git a/plugins/view-resources/src/filter/grammar/__tests__/parser.test.ts b/plugins/view-resources/src/filter/grammar/__tests__/parser.test.ts new file mode 100644 index 0000000000..79e0103659 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/__tests__/parser.test.ts @@ -0,0 +1,199 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { parseFilter } from '../parser' +import type { Node, ParseError } from '../types' +import { schema } from './fixtures' + +function ok (input: string): Node { + const res = parseFilter(input, schema) + if (!res.ok) throw new Error(`${input}: ${res.error.message}`) + return res.value +} + +function bad (input: string): ParseError { + const res = parseFilter(input, schema) + if (res.ok) throw new Error(`expected an error for ${input}`) + return res.error +} + +describe('parseFilter structure', () => { + it('parses an empty string to an empty AND', () => { + expect(ok('')).toEqual({ type: 'and', children: [] }) + }) + + it('parses free text', () => { + expect(ok('login')).toMatchObject({ type: 'text', value: 'login', quoted: false }) + expect(ok('"two words"')).toMatchObject({ type: 'text', value: 'two words', quoted: true }) + }) + + it('parses a field with several values as one OR term', () => { + const n = ok('status:Todo,Done') + expect(n).toMatchObject({ type: 'field', field: { name: 'status' } }) + expect(n.type === 'field' && n.values).toHaveLength(2) + }) + + it('combines repeated fields and separate terms with AND', () => { + const n = ok('status:Todo label:bug label:ui') + expect(n.type === 'and' && n.children).toHaveLength(3) + }) + + it('wraps negated terms in NOT', () => { + expect(ok('-status:Done')).toMatchObject({ type: 'not', child: { type: 'field' } }) + expect(ok('-login')).toMatchObject({ type: 'not', child: { type: 'text' } }) + }) + + it('gives AND higher precedence than OR', () => { + const n = ok('a b OR c') + expect(n.type).toBe('or') + expect(n.type === 'or' && n.children.map((c) => c.type)).toEqual(['and', 'text']) + }) + + it('accepts explicit AND', () => { + expect(ok('a AND b').type).toBe('and') + }) + + it('honours parentheses', () => { + const n = ok('a (b OR c)') + expect(n.type === 'and' && n.children[1].type).toBe('or') + }) + + it('supports negated groups', () => { + expect(ok('-(a OR b)')).toMatchObject({ type: 'not', child: { type: 'or' } }) + }) + + it('treats field names case-insensitively', () => { + expect(ok('Status:Done')).toMatchObject({ type: 'field', field: { name: 'status' } }) + }) +}) + +describe('parseFilter pseudo fields', () => { + it('parses has: and no:', () => { + expect(ok('has:assignee')).toMatchObject({ type: 'presence', present: true, field: { name: 'assignee' } }) + expect(ok('no:due')).toMatchObject({ type: 'presence', present: false }) + expect(ok('-no:due')).toMatchObject({ type: 'not', child: { type: 'presence' } }) + }) + + it('treats a comma list in has: as OR', () => { + expect(ok('has:assignee,due').type).toBe('or') + }) + + it('parses is:', () => { + expect(ok('is:open')).toMatchObject({ type: 'is', state: 'open' }) + expect(ok('is:open,closed').type).toBe('or') + }) + + it('rejects unknown is: values with hints', () => { + const e = bad('is:merged') + expect(e.code).toBe('unknownKeyword') + expect(e.hints).toContain('open') + }) + + it('rejects an unknown field in has:', () => { + const e = bad('has:nothing') + expect(e).toMatchObject({ code: 'unknownField', pos: 4, end: 11 }) + }) + + it('rejects values for presence-only fields', () => { + expect(bad('parent-issue:TSK-1').code).toBe('invalidOperator') + expect(ok('has:parent-issue').type).toBe('presence') + }) +}) + +describe('parseFilter values', () => { + it('parses comparison operators on numbers and dates', () => { + expect(ok('estimate:>5')).toMatchObject({ values: [{ kind: 'compare', op: '>', value: { kind: 'number', value: 5 } }] }) + expect(ok('estimate:<=2')).toMatchObject({ values: [{ kind: 'compare', op: '<=' }] }) + expect(ok('due:>=@today-7d')).toMatchObject({ + values: [{ kind: 'compare', op: '>=', value: { kind: 'date', amount: -7, unit: 'd' } }] + }) + }) + + it('parses inclusive ranges and wildcards', () => { + expect(ok('estimate:2..5')).toMatchObject({ values: [{ kind: 'range', from: { value: 2 }, to: { value: 5 } }] }) + const open = ok('estimate:3..*') + expect(open.type === 'field' && open.values[0]).toEqual({ kind: 'range', from: { kind: 'number', value: 3 }, to: undefined }) + const lower = ok('estimate:*..3') + expect(lower.type === 'field' && lower.values[0]).toMatchObject({ kind: 'range', from: undefined }) + expect(ok('due:2026-01-01..2026-02-01')).toMatchObject({ values: [{ kind: 'range' }] }) + }) + + it('parses @me for users only', () => { + expect(ok('assignee:@me')).toMatchObject({ values: [{ kind: 'eq', value: { kind: 'me' } }] }) + expect(ok('assignee:@alice')).toMatchObject({ values: [{ value: { kind: 'text', text: 'alice' } }] }) + expect(ok('title:@me')).toMatchObject({ values: [{ value: { kind: 'text', text: '@me' } }] }) + }) + + it('parses iteration keywords with arithmetic', () => { + expect(ok('iteration:@current')).toMatchObject({ values: [{ value: { kind: 'iteration', keyword: 'current', offset: 0 } }] }) + expect(ok('iteration:@current+1')).toMatchObject({ values: [{ value: { offset: 1 } }] }) + expect(ok('iteration:@previous,@next').type).toBe('field') + expect(ok('iteration:"Sprint 2"')).toMatchObject({ values: [{ value: { kind: 'text', text: 'Sprint 2' } }] }) + expect(ok('iteration:@current..@next')).toMatchObject({ values: [{ kind: 'range' }] }) + }) + + it('keeps a quoted operator or range as literal text', () => { + expect(ok('title:">5"')).toMatchObject({ values: [{ value: { kind: 'text', text: '>5' } }] }) + expect(ok('title:a..b')).toMatchObject({ values: [{ kind: 'eq', value: { text: 'a..b' } }] }) + }) +}) + +describe('parseFilter errors', () => { + it('reports unknown fields with position and hints', () => { + const e = bad('status:Done stat:x') + expect(e).toMatchObject({ code: 'unknownField', pos: 12, end: 16 }) + expect(e.hints).toContain('status') + }) + + it('reports missing values', () => { + expect(bad('status:')).toMatchObject({ code: 'missingValue' }) + expect(bad('estimate:>')).toMatchObject({ code: 'missingValue' }) + expect(bad('status:Todo,')).toMatchObject({ code: 'missingValue', pos: 12 }) + }) + + it('reports comparison on non-orderable fields', () => { + expect(bad('status:>Todo')).toMatchObject({ code: 'invalidOperator', pos: 7, end: 8 }) + expect(bad('title: { + expect(bad('estimate:abc')).toMatchObject({ code: 'invalidNumber', pos: 9 }) + expect(bad('estimate:1..x')).toMatchObject({ code: 'invalidNumber', pos: 12 }) + expect(bad('due:tomorrow')).toMatchObject({ code: 'invalidDate', pos: 4 }) + expect(bad('due:2026-02-31').code).toBe('invalidDate') + }) + + it('reports ranges with too many bounds', () => { + expect(bad('estimate:1..2..3').code).toBe('invalidRange') + }) + + it('reports unknown iteration keywords', () => { + const e = bad('iteration:@soon') + expect(e.code).toBe('unknownKeyword') + expect(e.hints).toContain('@current') + }) + + it('reports unbalanced parentheses', () => { + expect(bad('(a OR b')).toMatchObject({ code: 'unbalancedParenthesis', pos: 0 }) + expect(bad('a OR b)')).toMatchObject({ code: 'unbalancedParenthesis', pos: 6 }) + expect(bad('()').code).toBe('unbalancedParenthesis') + }) + + it('reports dangling operators', () => { + expect(bad('AND a').code).toBe('unexpectedToken') + expect(bad('a OR').code).toBe('unexpectedToken') + expect(bad('a AND OR b').code).toBe('unexpectedToken') + }) + + it('passes tokenizer errors through', () => { + expect(bad('title:"x')).toMatchObject({ code: 'unterminatedQuote', pos: 6 }) + }) + + it('never throws', () => { + for (const s of ['(', ')', 'OR', '-', '-(', 'is:', 'has:', 'no:,', ':', 'a:', '"', '..', 'estimate:..', 'due:*..*']) { + expect(() => parseFilter(s, schema)).not.toThrow() + } + }) +}) diff --git a/plugins/view-resources/src/filter/grammar/__tests__/suggest.test.ts b/plugins/view-resources/src/filter/grammar/__tests__/suggest.test.ts new file mode 100644 index 0000000000..5eb2030d9f --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/__tests__/suggest.test.ts @@ -0,0 +1,104 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { appendTerm, applySuggestion, joinAnd, suggest } from '../suggest' +import { schema } from './fixtures' + +const labels = (text: string, caret = text.length): string[] => suggest(text, caret, schema).items.map((i) => i.label) + +describe('suggest', () => { + it('offers all field names for an empty input', () => { + const res = suggest('', 0, schema) + expect(res.items.length).toBeGreaterThan(0) + expect(res.items[0].kind).toBe('field') + }) + + it('filters field names by prefix, including has/no/is', () => { + expect(labels('sta')).toEqual(['status']) + expect(labels('-ass')).toEqual(['assignee']) + expect(labels('i')).toEqual(expect.arrayContaining(['is', 'iteration'])) + expect(suggest('sta', 3, schema).items[0].insert).toBe('status:') + }) + + it('offers option names after the colon', () => { + expect(labels('status:')).toEqual(['Todo', 'In Progress', 'Done', 'Canceled']) + expect(labels('status:do')).toEqual(['Done']) + }) + + it('quotes values with spaces', () => { + expect(suggest('status:in', 9, schema).items[0].insert).toBe('"In Progress"') + }) + + it('completes only the value after the last comma', () => { + const res = suggest('status:Todo,d', 13, schema) + expect(res.items.map((i) => i.label)).toEqual(['Done']) + expect(applySuggestion('status:Todo,d', res, res.items[0]).text).toBe('status:Todo,Done') + }) + + it('offers keywords per field type', () => { + expect(labels('assignee:')).toContain('@me') + expect(labels('due:@')).toEqual(['@today', '@today-7d', '@today+7d']) + expect(labels('iteration:@c')).toEqual(['@current']) + }) + + it('offers field names after has: and no:, and is: values', () => { + expect(labels('has:a')).toEqual(expect.arrayContaining(['assignee', 'area'])) + expect(labels('no:du')).toEqual(['due']) + expect(labels('is:')).toEqual(['open', 'closed', 'issue', 'sub-issue']) + }) + + it('ignores comparison operators when completing', () => { + const res = suggest('due:>@to', 8, schema) + expect(res.items.map((i) => i.label)).toEqual(['@today', '@today-7d', '@today+7d']) + expect(applySuggestion('due:>@to', res, res.items[0]).text).toBe('due:>@today') + }) + + it('completes the term at the caret in the middle of the input', () => { + const text = 'status:Done sta label:bug' + const res = suggest(text, 15, schema) + expect(res.items.map((i) => i.label)).toEqual(['status']) + expect(applySuggestion(text, res, res.items[0]).text).toBe('status:Done status: label:bug') + }) + + it('does not treat a space inside quotes as a term boundary', () => { + expect(labels('status:"In Pr')).toEqual(['In Progress']) + }) + + it('has no suggestions for unknown fields', () => { + expect(labels('foo:')).toEqual([]) + }) +}) + +describe('appendTerm', () => { + it('starts a filter', () => { + expect(appendTerm('', 'status', 'Done')).toBe('status:Done') + }) + + it('adds an AND term and quotes when needed', () => { + expect(appendTerm('label:bug', 'status', 'In Progress')).toBe('label:bug status:"In Progress"') + }) + + it('does not repeat an existing term', () => { + expect(appendTerm('status:Done label:bug', 'status', 'Done')).toBe('status:Done label:bug') + }) + + it('parenthesizes a filter that has a top level OR', () => { + expect(appendTerm('a OR b', 'status', 'Done')).toBe('(a OR b) status:Done') + }) +}) + +describe('joinAnd', () => { + it('joins with a space and skips empty parts', () => { + expect(joinAnd('a', 'b')).toBe('a b') + expect(joinAnd('', 'b')).toBe('b') + expect(joinAnd(' a ', '')).toBe('a') + }) + + it('parenthesizes parts with a top level OR', () => { + expect(joinAnd('a OR b', 'c')).toBe('(a OR b) c') + expect(joinAnd('a', 'b OR c')).toBe('a (b OR c)') + expect(joinAnd('(a OR b)', 'c')).toBe('(a OR b) c') + }) +}) diff --git a/plugins/view-resources/src/filter/grammar/__tests__/tokenizer.test.ts b/plugins/view-resources/src/filter/grammar/__tests__/tokenizer.test.ts new file mode 100644 index 0000000000..d29684e101 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/__tests__/tokenizer.test.ts @@ -0,0 +1,108 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { tokenize } from '../tokenizer' +import type { Token } from '../types' + +function ok (input: string): Token[] { + const res = tokenize(input) + if (!res.ok) throw new Error(res.error.message) + return res.value +} + +describe('tokenize', () => { + it('returns no tokens for blank input', () => { + expect(ok('')).toEqual([]) + expect(ok(' \t ')).toEqual([]) + }) + + it('splits free text words', () => { + const t = ok('login bug') + expect(t).toHaveLength(2) + expect(t[0]).toMatchObject({ type: 'term', field: undefined, values: [{ text: 'login' }] }) + }) + + it('parses field:value with positions', () => { + const [t] = ok('status:Done') + expect(t).toMatchObject({ + type: 'term', + pos: 0, + end: 11, + negated: false, + field: { name: 'status', pos: 0, end: 6 }, + values: [{ text: 'Done', quoted: false, pos: 7, end: 11 }] + }) + }) + + it('splits comma separated values', () => { + const [t] = ok('status:Todo,Done,"In Progress"') + expect(t.type === 'term' && t.values.map((v) => [v.text, v.quoted])).toEqual([ + ['Todo', false], + ['Done', false], + ['In Progress', true] + ]) + }) + + it('keeps commas, colons and parentheses inside quotes', () => { + const [t] = ok('title:"a, b: (c)"') + expect(t.type === 'term' && t.values).toHaveLength(1) + expect(t.type === 'term' && t.values[0].text).toBe('a, b: (c)') + }) + + it('unescapes quotes inside quotes', () => { + const [t] = ok('title:"say \\"hi\\""') + expect(t.type === 'term' && t.values[0].text).toBe('say "hi"') + }) + + it('marks negation', () => { + const [t] = ok('-label:bug') + expect(t).toMatchObject({ type: 'term', negated: true, field: { name: 'label' } }) + }) + + it('treats a lone dash as text', () => { + const [t] = ok('-') + expect(t).toMatchObject({ type: 'term', negated: false, values: [{ text: '-' }] }) + }) + + it('recognizes AND, OR and parentheses', () => { + expect(ok('(a OR b) AND c').map((t) => t.type)).toEqual(['lparen', 'term', 'or', 'term', 'rparen', 'and', 'term']) + }) + + it('does not treat lower case or quoted and/or as operators', () => { + expect(ok('and or').map((t) => t.type)).toEqual(['term', 'term']) + expect(ok('"OR"').map((t) => t.type)).toEqual(['term']) + }) + + it('negates a group', () => { + expect(ok('-(a b)').map((t) => t.type)).toEqual(['not', 'lparen', 'term', 'term', 'rparen']) + }) + + it('does not treat a digit-led prefix as a field (times, versions)', () => { + const [t] = ok('12:30') + expect(t).toMatchObject({ type: 'term', field: undefined, values: [{ text: '12:30' }] }) + }) + + it('accepts field names in any script', () => { + const [t] = ok('приоритет:высокий') + expect(t).toMatchObject({ type: 'term', field: { name: 'приоритет' }, values: [{ text: 'высокий' }] }) + }) + + it('keeps an empty value after the colon', () => { + const [t] = ok('status:') + expect(t.type === 'term' && t.values).toEqual([{ text: '', quoted: false, pos: 7, end: 7 }]) + }) + + it('reports an unterminated quote with its position', () => { + const res = tokenize('status:Done title:"oops') + expect(res.ok).toBe(false) + if (!res.ok) expect(res.error).toMatchObject({ code: 'unterminatedQuote', pos: 18 }) + }) + + it('never throws on arbitrary input', () => { + for (const s of ['"', '\\', '))((', ':', ',,,', '-:', '"a" "b', ':::', 'a:"', '\u0000']) { + expect(() => tokenize(s)).not.toThrow() + } + }) +}) diff --git a/plugins/view-resources/src/filter/grammar/__tests__/values.test.ts b/plugins/view-resources/src/filter/grammar/__tests__/values.test.ts new file mode 100644 index 0000000000..b6f431d3c2 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/__tests__/values.test.ts @@ -0,0 +1,127 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + containsGlob, + endOfDay, + matchGlob, + parseDateScalar, + parseIterationScalar, + resolveDate, + resolveIteration, + startOfDay +} from '../values' +import { ITERATIONS, NOW } from './fixtures' + +function date (text: string): number { + const s = parseDateScalar(text) + if (s?.kind !== 'date') throw new Error(`not a date: ${text}`) + return resolveDate(s, NOW) +} + +describe('dates', () => { + it('parses absolute dates and rejects non-existing ones', () => { + expect(date('2026-02-03')).toBe(new Date(2026, 1, 3).getTime()) + expect(parseDateScalar('2026-02-30')).toBeUndefined() + expect(parseDateScalar('2026-13-01')).toBeUndefined() + expect(parseDateScalar('yesterday')).toBeUndefined() + }) + + it('resolves @today to the start of the current day', () => { + expect(date('@today')).toBe(startOfDay(NOW)) + }) + + it('applies day, week, month and year offsets', () => { + expect(date('@today-7d')).toBe(new Date(2026, 5, 10).getTime()) + expect(date('@today+1w')).toBe(new Date(2026, 5, 24).getTime()) + expect(date('@today-1m')).toBe(new Date(2026, 4, 17).getTime()) + expect(date('@today+1y')).toBe(new Date(2027, 5, 17).getTime()) + }) + + it('clamps month arithmetic to the end of a shorter month', () => { + const jan31 = new Date(2026, 0, 31, 12).getTime() + const s = parseDateScalar('@today+1m') + expect(s?.kind === 'date' && resolveDate(s, jan31)).toBe(new Date(2026, 1, 28).getTime()) + }) + + it('rejects an offset without unit', () => { + expect(parseDateScalar('@today-7')).toBeUndefined() + }) + + it('day bounds cover the whole day', () => { + expect(endOfDay(NOW) - startOfDay(NOW)).toBe(24 * 60 * 60 * 1000 - 1) + }) +}) + +describe('iterations', () => { + const resolve = (text: string): string | undefined => { + const s = parseIterationScalar(text) + if (s?.kind !== 'iteration') throw new Error(text) + return resolveIteration(s, ITERATIONS, NOW)?.id + } + + it('parses keywords with arithmetic', () => { + expect(parseIterationScalar('@current')).toEqual({ kind: 'iteration', keyword: 'current', offset: 0 }) + expect(parseIterationScalar('@current+1')).toEqual({ kind: 'iteration', keyword: 'current', offset: 1 }) + expect(parseIterationScalar('@next-2')).toEqual({ kind: 'iteration', keyword: 'next', offset: -2 }) + expect(parseIterationScalar('@soon')).toBeUndefined() + }) + + it('resolves current, next and previous', () => { + expect(resolve('@current')).toBe('it2') + expect(resolve('@next')).toBe('it3') + expect(resolve('@previous')).toBe('it1') + }) + + it('resolves arithmetic', () => { + expect(resolve('@current+1')).toBe('it3') + expect(resolve('@current+2')).toBe('it4') + expect(resolve('@current-1')).toBe('it1') + expect(resolve('@next+1')).toBe('it4') + }) + + it('returns undefined past the ends', () => { + expect(resolve('@current+3')).toBeUndefined() + expect(resolve('@current-2')).toBeUndefined() + expect(resolve('@previous-1')).toBeUndefined() + }) + + it('handles a gap between iterations', () => { + const gap = new Date(2026, 5, 14, 12).getTime() // between it1 end (14th 23:59) -> inside; use list without it2 + const list = ITERATIONS.filter((i) => i.id !== 'it2') + const now = new Date(2026, 5, 20).getTime() + const at = (kw: 'current' | 'next' | 'previous'): string | undefined => + resolveIteration({ kind: 'iteration', keyword: kw, offset: 0 }, list, now)?.id + expect(gap).toBeGreaterThan(0) + expect(at('current')).toBeUndefined() + expect(at('next')).toBe('it3') + expect(at('previous')).toBe('it1') + }) + + it('returns undefined without iterations', () => { + expect(resolveIteration({ kind: 'iteration', keyword: 'current', offset: 0 }, [], NOW)).toBeUndefined() + }) +}) + +describe('globs', () => { + it('matches whole labels case-insensitively without wildcard', () => { + expect(matchGlob('done', 'Done')).toBe(true) + expect(matchGlob('don', 'Done')).toBe(false) + }) + + it('supports wildcards at any position', () => { + expect(matchGlob('in*', 'In Progress')).toBe(true) + expect(matchGlob('*gress', 'In Progress')).toBe(true) + expect(matchGlob('i*s', 'In Progress')).toBe(true) + expect(matchGlob('a*a', 'a')).toBe(false) + expect(matchGlob('*', '')).toBe(true) + }) + + it('contains is a substring match', () => { + expect(containsGlob('login', 'Fix Login bug')).toBe(true) + expect(containsGlob('fix*bug', 'Fix login bug')).toBe(true) + expect(containsGlob('bug*fix', 'Fix login bug')).toBe(false) + }) +}) diff --git a/plugins/view-resources/src/filter/grammar/compile.d.ts b/plugins/view-resources/src/filter/grammar/compile.d.ts new file mode 100644 index 0000000000..6205e3d7bf --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/compile.d.ts @@ -0,0 +1,52 @@ +import type { FieldSpec, FilterContext, Node, ParseError } from './types'; +/** + * Server-side part of a filter and what is left for the client. + * @public + */ +export interface SplitResult { + query: Record; + residual?: Node; +} +/** + * Splits a filter into the part a `DocumentQuery` can express and the part the client has to evaluate. + * The top level AND is taken apart, each conjunct goes to the server when it is a plain condition on + * an indexed attribute. Anything else, in particular an OR across different fields or across server + * and client fields, is kept whole in the residual so that the combined result stays correct. + * Conditions on `reservedKeys` (attributes the host query already constrains) are left to the client too. + * @public + */ +export declare function splitServerClient(ast: Node, ctx: FilterContext, reservedKeys?: ReadonlySet): SplitResult; +/** + * Query of a filter that is fully evaluated on the server, or undefined when part of it needs the client. + * @public + */ +export declare function compileQuery(ast: Node, ctx: FilterContext): Record | undefined; +/** + * A parsed filter ready to be applied. + * @public + */ +export interface CompiledFilter { + ast: Node; + query: Record; + residual?: Node; + predicate: (doc: any) => boolean; + matches: (doc: any) => boolean; +} +/** + * Parses and compiles a filter string. An empty string matches everything. + * @public + */ +export declare function compileFilter(input: string, schema: readonly FieldSpec[], ctx: FilterContext): { + ok: true; + value: CompiledFilter; +} | { + ok: false; + error: ParseError; +}; +/** + * Document properties a client evaluation of the node reads, for projecting a scan: + * attributes by name, plus `customFields` when a user-defined field is involved. + * @public + */ +export declare function referencedProperties(node: Node | undefined): string[]; +//# sourceMappingURL=compile.d.ts.map \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/compile.d.ts.map b/plugins/view-resources/src/filter/grammar/compile.d.ts.map new file mode 100644 index 0000000000..904b7f5382 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/compile.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"compile.d.ts","sourceRoot":"","sources":["compile.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAc,aAAa,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AAQrF;;;GAGG;AACH,MAAM,WAAW,WAAW;IAE1B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAE1B,QAAQ,CAAC,EAAE,IAAI,CAAA;CAChB;AAgJD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAE,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,aAAa,EAAE,YAAY,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,WAAW,CASjH;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAE,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAG5F;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,IAAI,CAAA;IAET,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAE1B,QAAQ,CAAC,EAAE,IAAI,CAAA;IAEf,SAAS,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAA;IAEhC,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAA;CAC/B;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,SAAS,SAAS,EAAE,EAC5B,GAAG,EAAE,aAAa,GACjB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,cAAc,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,CAexE;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAE,IAAI,EAAE,IAAI,GAAG,SAAS,GAAG,MAAM,EAAE,CA2BtE"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/compile.ts b/plugins/view-resources/src/filter/grammar/compile.ts new file mode 100644 index 0000000000..1a66e253a6 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/compile.ts @@ -0,0 +1,270 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { createPredicate, numericMatch, optionIdsFor } from './evaluate' +import { parseFilter } from './parser' +import type { FieldSpec, FieldValue, FilterContext, Node, ParseError } from './types' + +// Compilation of the AST into a Huly `DocumentQuery`. A document query is a conjunction of one +// selector per attribute: it has no OR, so everything it cannot express stays in a residual AST +// that the client evaluates over the already narrowed result (see `splitServerClient`). + +type Clause = Record + +/** + * Server-side part of a filter and what is left for the client. + * @public + */ +export interface SplitResult { + // Selectors the server evaluates. Always a safe narrowing of the result + query: Record + // Part of the filter the server cannot evaluate; undefined when the query is exact + residual?: Node +} + +function conjuncts (node: Node): Node[] { + return node.type === 'and' ? node.children.flatMap(conjuncts) : [node] +} + +function isPlainSelector (v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +// A clause is added only when it does not collide with a selector already in the query +function mergeClause (query: Record, clause: Clause, reserved?: ReadonlySet): boolean { + for (const [key, sel] of Object.entries(clause)) { + if (reserved?.has(key) === true) return false + if (!(key in query)) continue + const existing = query[key] + if (!isPlainSelector(existing) || !isPlainSelector(sel)) return false + if (Object.keys(sel).some((op) => op in existing)) return false + } + for (const [key, sel] of Object.entries(clause)) { + query[key] = key in query ? { ...query[key], ...sel } : sel + } + return true +} + +// `%`, `_` and `\` are LIKE metacharacters; such text is left to the client instead of being escaped +const LIKE_UNSAFE = /[%_\\]/ + +function textClause (value: string): Clause | undefined { + if (LIKE_UNSAFE.test(value)) return undefined + return { title: { $like: `%${value.replaceAll('*', '%')}%` } } +} + +function serverField (field: FieldSpec): boolean { + return field.source === 'attribute' && field.clientOnly !== true +} + +function selectedIds (field: FieldSpec, values: readonly FieldValue[], ctx: FilterContext): Array | undefined { + const ids = new Set() + for (const v of values) { + if (v.kind !== 'eq') return undefined + for (const id of optionIdsFor(field, v.value, ctx)) ids.add(id) + } + return [...ids] +} + +function fieldClause (field: FieldSpec, values: readonly FieldValue[], negated: boolean, ctx: FilterContext): Clause | undefined { + if (!serverField(field)) return undefined + + if (field.resolveDocIds !== undefined) { + const ids = selectedIds(field, values, ctx) + if (ids === undefined) return undefined + const docs = field.resolveDocIds(ids) + return { _id: negated ? { $nin: docs } : { $in: docs } } + } + + switch (field.type) { + case 'text': { + if (negated || values.length !== 1) return undefined + const v = values[0] + return v.kind === 'eq' && v.value.kind === 'text' ? textClause(v.value.text) : undefined + } + case 'select': + case 'user': { + const ids = selectedIds(field, values, ctx) + if (ids === undefined) return undefined + return { [field.key]: negated ? { $nin: ids } : { $in: ids } } + } + case 'number': + case 'date': { + if (negated || values.length !== 1) return undefined + const m = numericMatch(field, values[0], ctx) + // A value that cannot match anything selects nothing + if (m === undefined) return { [field.key]: { $in: [] } } + const sel: Record = {} + if (m.lo !== undefined) sel[m.loExclusive === true ? '$gt' : '$gte'] = m.lo + if (m.hi !== undefined) sel[m.hiExclusive === true ? '$lt' : '$lte'] = m.hi + return { [field.key]: Object.keys(sel).length > 0 ? sel : { $ne: null } } + } + default: + return undefined + } +} + +function presenceClause (field: FieldSpec, present: boolean): Clause | undefined { + if (!serverField(field) && field.presenceQuery === undefined) return undefined + if (field.presenceQuery !== undefined) return field.presenceQuery(present) + switch (field.type) { + case 'select': + case 'user': + case 'number': + case 'date': + return { [field.key]: present ? { $ne: null } : null } + default: + return undefined + } +} + +// `a:x OR a:y` on one field is the same as `a:x,y` +function mergeSameField (node: Extract): Node | undefined { + const first = node.children[0] + if (first?.type !== 'field') return undefined + const values: FieldValue[] = [] + for (const c of node.children) { + if (c.type !== 'field' || c.field !== first.field) return undefined + values.push(...c.values) + } + return { type: 'field', field: first.field, values, pos: first.pos } +} + +function compileClause (node: Node, ctx: FilterContext): Clause | undefined { + switch (node.type) { + case 'text': + return textClause(node.value) + case 'field': + return fieldClause(node.field, node.values, false, ctx) + case 'presence': + return presenceClause(node.field, node.present) + case 'is': + switch (node.state) { + case 'issue': + return {} + case 'open': + case 'closed': { + if (ctx.closedStatuses === undefined) return undefined + const ids = [...ctx.closedStatuses] + return { status: node.state === 'open' ? { $nin: ids } : { $in: ids } } + } + default: + return ctx.noParentId === undefined ? undefined : { attachedTo: { $ne: ctx.noParentId } } + } + case 'or': { + const merged = mergeSameField(node) + return merged !== undefined ? compileClause(merged, ctx) : undefined + } + case 'not': { + const inner = node.child.type === 'or' ? (mergeSameField(node.child) ?? node.child) : node.child + return inner.type === 'field' ? fieldClause(inner.field, inner.values, true, ctx) : undefined + } + default: + return undefined + } +} + +/** + * Splits a filter into the part a `DocumentQuery` can express and the part the client has to evaluate. + * The top level AND is taken apart, each conjunct goes to the server when it is a plain condition on + * an indexed attribute. Anything else, in particular an OR across different fields or across server + * and client fields, is kept whole in the residual so that the combined result stays correct. + * Conditions on `reservedKeys` (attributes the host query already constrains) are left to the client too. + * @public + */ +export function splitServerClient (ast: Node, ctx: FilterContext, reservedKeys?: ReadonlySet): SplitResult { + const query: Record = {} + const rest: Node[] = [] + for (const c of conjuncts(ast)) { + const clause = compileClause(c, ctx) + if (clause === undefined || !mergeClause(query, clause, reservedKeys)) rest.push(c) + } + const residual = rest.length === 0 ? undefined : rest.length === 1 ? rest[0] : ({ type: 'and', children: rest } as Node) + return { query, residual } +} + +/** + * Query of a filter that is fully evaluated on the server, or undefined when part of it needs the client. + * @public + */ +export function compileQuery (ast: Node, ctx: FilterContext): Record | undefined { + const { query, residual } = splitServerClient(ast, ctx) + return residual === undefined ? query : undefined +} + +/** + * A parsed filter ready to be applied. + * @public + */ +export interface CompiledFilter { + ast: Node + // Narrowing the server applies + query: Record + // Client evaluation of what the server cannot do; undefined when the query is exact + residual?: Node + // Evaluates the residual (always true when there is none) + predicate: (doc: any) => boolean + // Evaluates the whole filter on the client, independently of the split + matches: (doc: any) => boolean +} + +/** + * Parses and compiles a filter string. An empty string matches everything. + * @public + */ +export function compileFilter ( + input: string, + schema: readonly FieldSpec[], + ctx: FilterContext +): { ok: true, value: CompiledFilter } | { ok: false, error: ParseError } { + const parsed = parseFilter(input, schema) + if (!parsed.ok) return parsed + const ast = parsed.value + const { query, residual } = splitServerClient(ast, ctx) + return { + ok: true, + value: { + ast, + query, + residual, + predicate: createPredicate(residual, ctx), + matches: createPredicate(ast, ctx) + } + } +} + +/** + * Document properties a client evaluation of the node reads, for projecting a scan: + * attributes by name, plus `customFields` when a user-defined field is involved. + * @public + */ +export function referencedProperties (node: Node | undefined): string[] { + const props = new Set(['_id']) + const visit = (n: Node): void => { + switch (n.type) { + case 'and': + case 'or': + n.children.forEach(visit) + break + case 'not': + visit(n.child) + break + case 'text': + props.add('title') + break + case 'field': + case 'presence': + if (n.field.source === 'custom') props.add('customFields') + else props.add(n.field.key) + n.field.dependsOn?.forEach((d) => props.add(d)) + break + case 'is': + props.add(n.state === 'sub-issue' ? 'attachedTo' : 'status') + break + } + } + if (node !== undefined) visit(node) + return [...props] +} diff --git a/plugins/view-resources/src/filter/grammar/evaluate.d.ts b/plugins/view-resources/src/filter/grammar/evaluate.d.ts new file mode 100644 index 0000000000..6e292b51cd --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/evaluate.d.ts @@ -0,0 +1,49 @@ +import type { FieldSpec, FieldValue, FilterContext, Node, Scalar } from './types'; +/** + * Raw value of a field in a document. + * @public + */ +export declare function readFieldValue(field: FieldSpec, doc: any): unknown; +/** + * A value counts as empty when it is missing, null, an empty string or an empty list. + * @public + */ +export declare function isEmptyValue(value: unknown): boolean; +/** + * Option ids a select / user / multi value refers to. A text value is matched against the option + * labels (`*` wildcards) and, as a fallback, against the ids themselves. + * @public + */ +export declare function optionIdsFor(field: FieldSpec, scalar: Scalar, ctx: FilterContext): Array; +/** + * Window of numeric / date comparisons for the compiler. Numbers compare exactly (so `>5` is `> 5`), + * which needs a flag; dates are widened to whole days and need none. + * @public + */ +export interface NumericMatch { + lo?: number; + hi?: number; + loExclusive?: boolean; + hiExclusive?: boolean; +} +/** + * Bounds of a number / date value, or undefined when the value cannot match anything. + * @public + */ +export declare function numericMatch(field: FieldSpec, value: FieldValue, ctx: FilterContext): NumericMatch | undefined; +/** + * Whether a document matches one `field:value` term (any of its comma separated values). + * @public + */ +export declare function matchesField(field: FieldSpec, values: readonly FieldValue[], doc: any, ctx: FilterContext): boolean; +/** + * Whether a document matches the AST. + * @public + */ +export declare function evaluate(node: Node, doc: any, ctx: FilterContext): boolean; +/** + * Predicate for a node, or for "everything" when there is nothing left to evaluate. + * @public + */ +export declare function createPredicate(node: Node | undefined, ctx: FilterContext): (doc: any) => boolean; +//# sourceMappingURL=evaluate.d.ts.map \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/evaluate.d.ts.map b/plugins/view-resources/src/filter/grammar/evaluate.d.ts.map new file mode 100644 index 0000000000..567c9be672 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/evaluate.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"evaluate.d.ts","sourceRoot":"","sources":["evaluate.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,aAAa,EAAiB,IAAI,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAMhG;;;GAGG;AACH,wBAAgB,cAAc,CAAE,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,GAAG,GAAG,OAAO,CAInE;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAErD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,aAAa,GAAG,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAO1G;AAgFD;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,EAAE,CAAC,EAAE,MAAM,CAAA;IAEX,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,WAAW,CAAC,EAAE,OAAO,CAAA;CACtB;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,aAAa,GAAG,YAAY,GAAG,SAAS,CAS/G;AAyDD;;;GAGG;AACH,wBAAgB,YAAY,CAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,UAAU,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,aAAa,GAAG,OAAO,CAGpH;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAE,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,aAAa,GAAG,OAAO,CA6B3E;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAE,IAAI,EAAE,IAAI,GAAG,SAAS,EAAE,GAAG,EAAE,aAAa,GAAG,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAGlG"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/evaluate.ts b/plugins/view-resources/src/filter/grammar/evaluate.ts new file mode 100644 index 0000000000..17cd468576 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/evaluate.ts @@ -0,0 +1,256 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { FieldSpec, FieldValue, FilterContext, IterationInfo, Node, Scalar } from './types' +import { containsGlob, endOfDay, matchGlob, resolveDate, resolveIteration } from './values' + +// Client-side evaluation of a parsed filter over a document. It defines the semantics of the +// grammar; the server query built by compile.ts must select the same documents. + +/** + * Raw value of a field in a document. + * @public + */ +export function readFieldValue (field: FieldSpec, doc: any): unknown { + if (field.read !== undefined) return field.read(doc) + if (field.source === 'custom') return doc?.customFields?.[field.key] + return doc?.[field.key] +} + +/** + * A value counts as empty when it is missing, null, an empty string or an empty list. + * @public + */ +export function isEmptyValue (value: unknown): boolean { + return value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0) +} + +/** + * Option ids a select / user / multi value refers to. A text value is matched against the option + * labels (`*` wildcards) and, as a fallback, against the ids themselves. + * @public + */ +export function optionIdsFor (field: FieldSpec, scalar: Scalar, ctx: FilterContext): Array { + if (scalar.kind === 'me') return ctx.me !== undefined ? [ctx.me] : [] + if (scalar.kind !== 'text') return [] + const options = field.options ?? [] + const byName = options.filter((o) => matchGlob(scalar.text, o.name)).map((o) => o.id) + if (byName.length > 0) return byName + return options.filter((o) => String(o.id) === scalar.text).map((o) => o.id) +} + +/** + * Inclusive bounds of the days a date scalar covers; for one day they are its first and last millisecond. + */ +function dayBounds (scalar: Scalar, ctx: FilterContext): { start: number, end: number } | undefined { + if (scalar.kind !== 'date') return undefined + const start = resolveDate(scalar, ctx.now) + return { start, end: endOfDay(start) } +} + +function numberOf (scalar: Scalar | undefined): number | undefined { + return scalar?.kind === 'number' ? scalar.value : undefined +} + +function iterationOf ( + field: FieldSpec, + scalar: Scalar | undefined, + ctx: FilterContext +): IterationInfo | undefined { + if (scalar === undefined) return undefined + const list = ctx.iterations?.(field.key) ?? [] + if (scalar.kind === 'iteration') return resolveIteration(scalar, list, ctx.now) + if (scalar.kind === 'text') return list.find((it) => matchGlob(scalar.text, it.title) || it.id === scalar.text) + return undefined +} + +/** + * The window of the value a bound-based match works on: [lo, hi] where a missing bound is open. + * `exclusiveLo` / `exclusiveHi` are not needed, because every bound is expressed inclusively. + */ +interface Window { + lo?: number + hi?: number +} + +function windowOf (field: FieldSpec, value: FieldValue, ctx: FilterContext): Window | 'none' { + if (field.type === 'number') { + switch (value.kind) { + case 'eq': + return numberOf(value.value) === undefined ? 'none' : { lo: numberOf(value.value), hi: numberOf(value.value) } + case 'compare': { + const n = numberOf(value.value) + if (n === undefined) return 'none' + // Strict comparisons are expressed on the closed window by the caller + return value.op === '>' || value.op === '>=' ? { lo: n } : { hi: n } + } + case 'range': + return { lo: numberOf(value.from), hi: numberOf(value.to) } + } + } + // Dates: whole calendar days + switch (value.kind) { + case 'eq': { + const d = dayBounds(value.value, ctx) + return d === undefined ? 'none' : { lo: d.start, hi: d.end } + } + case 'compare': { + const d = dayBounds(value.value, ctx) + if (d === undefined) return 'none' + switch (value.op) { + case '>': + return { lo: d.end + 1 } + case '>=': + return { lo: d.start } + case '<': + return { hi: d.start - 1 } + default: + return { hi: d.end } + } + } + case 'range': { + const from = value.from !== undefined ? dayBounds(value.from, ctx) : undefined + const to = value.to !== undefined ? dayBounds(value.to, ctx) : undefined + if ((value.from !== undefined && from === undefined) || (value.to !== undefined && to === undefined)) return 'none' + return { lo: from?.start, hi: to?.end } + } + } +} + +/** + * Window of numeric / date comparisons for the compiler. Numbers compare exactly (so `>5` is `> 5`), + * which needs a flag; dates are widened to whole days and need none. + * @public + */ +export interface NumericMatch { + lo?: number + hi?: number + // Bounds exclude their value (numbers only) + loExclusive?: boolean + hiExclusive?: boolean +} + +/** + * Bounds of a number / date value, or undefined when the value cannot match anything. + * @public + */ +export function numericMatch (field: FieldSpec, value: FieldValue, ctx: FilterContext): NumericMatch | undefined { + const w = windowOf(field, value, ctx) + if (w === 'none') return undefined + const res: NumericMatch = { lo: w.lo, hi: w.hi } + if (field.type === 'number' && value.kind === 'compare') { + if (value.op === '>') res.loExclusive = true + if (value.op === '<') res.hiExclusive = true + } + return res +} + +function inWindow (v: number, m: NumericMatch): boolean { + if (m.lo !== undefined && (m.loExclusive === true ? v <= m.lo : v < m.lo)) return false + if (m.hi !== undefined && (m.hiExclusive === true ? v >= m.hi : v > m.hi)) return false + return true +} + +function matchValue (field: FieldSpec, value: FieldValue, raw: unknown, ctx: FilterContext): boolean { + switch (field.type) { + case 'text': { + if (value.kind !== 'eq' || value.value.kind !== 'text' || typeof raw !== 'string') return false + return containsGlob(value.value.text, raw) + } + case 'number': + case 'date': { + if (typeof raw !== 'number' || !Number.isFinite(raw)) return false + const m = numericMatch(field, value, ctx) + return m !== undefined && inWindow(raw, m) + } + case 'iteration': { + if (typeof raw !== 'string') return false + if (value.kind === 'eq') { + const target = iterationOf(field, value.value, ctx) + return target !== undefined && target.id === raw + } + const own = (ctx.iterations?.(field.key) ?? []).find((it) => it.id === raw) + if (own === undefined) return false + if (value.kind === 'compare') { + const ref = iterationOf(field, value.value, ctx) + if (ref === undefined) return false + switch (value.op) { + case '>': + return own.start > ref.start + case '>=': + return own.start >= ref.start + case '<': + return own.start < ref.start + default: + return own.start <= ref.start + } + } + const from = value.from !== undefined ? iterationOf(field, value.from, ctx) : undefined + const to = value.to !== undefined ? iterationOf(field, value.to, ctx) : undefined + if ((value.from !== undefined && from === undefined) || (value.to !== undefined && to === undefined)) return false + return (from === undefined || own.start >= from.start) && (to === undefined || own.start <= to.start) + } + default: { + // select, user, multi + if (value.kind !== 'eq') return false + const ids = new Set(optionIdsFor(field, value.value, ctx).map(String)) + if (Array.isArray(raw)) return raw.some((x) => ids.has(String(x))) + return !isEmptyValue(raw) && ids.has(String(raw)) + } + } +} + +/** + * Whether a document matches one `field:value` term (any of its comma separated values). + * @public + */ +export function matchesField (field: FieldSpec, values: readonly FieldValue[], doc: any, ctx: FilterContext): boolean { + const raw = readFieldValue(field, doc) + return values.some((v) => matchValue(field, v, raw, ctx)) +} + +/** + * Whether a document matches the AST. + * @public + */ +export function evaluate (node: Node, doc: any, ctx: FilterContext): boolean { + switch (node.type) { + case 'and': + return node.children.every((c) => evaluate(c, doc, ctx)) + case 'or': + return node.children.some((c) => evaluate(c, doc, ctx)) + case 'not': + return !evaluate(node.child, doc, ctx) + case 'text': + return containsGlob(node.value, String(doc?.title ?? '')) + case 'field': + return matchesField(node.field, node.values, doc, ctx) + case 'presence': { + const empty = isEmptyValue(readFieldValue(node.field, doc)) + return node.present ? !empty : empty + } + case 'is': { + switch (node.state) { + case 'open': + return !(ctx.closedStatuses?.has(String(doc?.status)) ?? false) + case 'closed': + return ctx.closedStatuses?.has(String(doc?.status)) ?? false + case 'sub-issue': + return ctx.noParentId !== undefined && doc?.attachedTo !== ctx.noParentId + default: + return true + } + } + } +} + +/** + * Predicate for a node, or for "everything" when there is nothing left to evaluate. + * @public + */ +export function createPredicate (node: Node | undefined, ctx: FilterContext): (doc: any) => boolean { + if (node === undefined) return () => true + return (doc) => evaluate(node, doc, ctx) +} diff --git a/plugins/view-resources/src/filter/grammar/index.d.ts b/plugins/view-resources/src/filter/grammar/index.d.ts new file mode 100644 index 0000000000..067d65f09c --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/index.d.ts @@ -0,0 +1,8 @@ +export * from './compile'; +export * from './evaluate'; +export * from './parser'; +export * from './suggest'; +export * from './tokenizer'; +export * from './types'; +export * from './values'; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/index.d.ts.map b/plugins/view-resources/src/filter/grammar/index.d.ts.map new file mode 100644 index 0000000000..1c08d83113 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAKA,cAAc,WAAW,CAAA;AACzB,cAAc,YAAY,CAAA;AAC1B,cAAc,UAAU,CAAA;AACxB,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,SAAS,CAAA;AACvB,cAAc,UAAU,CAAA"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/index.ts b/plugins/view-resources/src/filter/grammar/index.ts new file mode 100644 index 0000000000..f21f91031d --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/index.ts @@ -0,0 +1,12 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +export * from './compile' +export * from './evaluate' +export * from './parser' +export * from './suggest' +export * from './tokenizer' +export * from './types' +export * from './values' diff --git a/plugins/view-resources/src/filter/grammar/parser.d.ts b/plugins/view-resources/src/filter/grammar/parser.d.ts new file mode 100644 index 0000000000..ddb409fc98 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/parser.d.ts @@ -0,0 +1,35 @@ +import type { FieldSpec, FieldValue, IsState, Node, ParseError, Result, ValueToken } from './types'; +/** Values accepted by `is:`. `issue` and `sub-issue` mirror GitHub's item kinds. */ +export declare const IS_VALUES: IsState[]; +/** Pseudo fields that are not part of the schema. */ +export declare const RESERVED_FIELDS: string[]; +type Tagged = { + ok: true; + value: T; +} | { + ok: false; + error: ParseError; +}; +/** + * Field names that look like `typed`, best matches first, for "unknown field" hints. + * @public + */ +export declare function similarFieldNames(typed: string, names: readonly string[]): string[]; +/** + * Resolves a typed field name in the schema (case-insensitive). + * @public + */ +export declare function findField(schema: readonly FieldSpec[], name: string): FieldSpec | undefined; +/** + * Parses one comma separated value of `field:value` according to the field type. + * @public + */ +export declare function parseFieldValue(spec: FieldSpec, v: ValueToken): Tagged; +/** + * Parses a filter string into an AST. Never throws; errors carry the offending position. + * An empty string yields an empty AND that matches everything. + * @public + */ +export declare function parseFilter(input: string, schema: readonly FieldSpec[]): Result; +export {}; +//# sourceMappingURL=parser.d.ts.map \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/parser.d.ts.map b/plugins/view-resources/src/filter/grammar/parser.d.ts.map new file mode 100644 index 0000000000..565eeec553 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/parser.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["parser.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAEV,SAAS,EAET,UAAU,EACV,OAAO,EACP,IAAI,EACJ,UAAU,EAEV,MAAM,EAGN,UAAU,EACX,MAAM,SAAS,CAAA;AAGhB,oFAAoF;AACpF,eAAO,MAAM,SAAS,EAAE,OAAO,EAA6C,CAAA;AAE5E,qDAAqD;AACrD,eAAO,MAAM,eAAe,UAAsB,CAAA;AAIlD,KAAK,MAAM,CAAC,CAAC,IAAI;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,CAAA;AAM1E;;;GAGG;AACH,wBAAgB,iBAAiB,CAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAKpF;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAE,MAAM,EAAE,SAAS,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAG5F;AA8LD;;;GAGG;AACH,wBAAgB,eAAe,CAAE,IAAI,EAAE,SAAS,EAAE,CAAC,EAAE,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC,CAkCnF;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,SAAS,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,CAItF"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/parser.ts b/plugins/view-resources/src/filter/grammar/parser.ts new file mode 100644 index 0000000000..79b0b8ec27 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/parser.ts @@ -0,0 +1,294 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { tokenize } from './tokenizer' +import type { + CompareOp, + FieldSpec, + FieldType, + FieldValue, + IsState, + Node, + ParseError, + ParseErrorCode, + Result, + Scalar, + Token, + ValueToken +} from './types' +import { parseDateScalar, parseIterationScalar } from './values' + +/** Values accepted by `is:`. `issue` and `sub-issue` mirror GitHub's item kinds. */ +export const IS_VALUES: IsState[] = ['open', 'closed', 'issue', 'sub-issue'] + +/** Pseudo fields that are not part of the schema. */ +export const RESERVED_FIELDS = ['has', 'no', 'is'] + +const ORDERED_TYPES: FieldType[] = ['number', 'date', 'iteration'] + +type Tagged = { ok: true, value: T } | { ok: false, error: ParseError } + +function err (code: ParseErrorCode, message: string, pos: number, end: number, hints?: string[]): Tagged { + return { ok: false, error: { code, message, pos, end, hints } } +} + +/** + * Field names that look like `typed`, best matches first, for "unknown field" hints. + * @public + */ +export function similarFieldNames (typed: string, names: readonly string[]): string[] { + const t = typed.toLowerCase() + const starts = names.filter((n) => n.startsWith(t)) + const contains = names.filter((n) => !n.startsWith(t) && (n.includes(t) || t.includes(n))) + return [...starts, ...contains].slice(0, 5) +} + +/** + * Resolves a typed field name in the schema (case-insensitive). + * @public + */ +export function findField (schema: readonly FieldSpec[], name: string): FieldSpec | undefined { + const n = name.toLowerCase() + return schema.find((f) => f.name === n) +} + +class Parser { + private i = 0 + + constructor ( + private readonly tokens: Token[], + private readonly schema: readonly FieldSpec[] + ) {} + + parse (): Tagged { + if (this.tokens.length === 0) return { ok: true, value: { type: 'and', children: [] } } + const res = this.parseOr() + if (!res.ok) return res + const rest = this.tokens[this.i] + if (rest !== undefined) { + if (rest.type === 'rparen') return err('unbalancedParenthesis', 'Unmatched ")"', rest.pos, rest.end) + return err('unexpectedToken', 'Unexpected input', rest.pos, rest.end) + } + return res + } + + private peek (): Token | undefined { + return this.tokens[this.i] + } + + private parseOr (): Tagged { + const first = this.parseAnd() + if (!first.ok) return first + const children: Node[] = [first.value] + while (this.peek()?.type === 'or') { + this.i++ + const next = this.parseAnd() + if (!next.ok) return next + children.push(next.value) + } + return { ok: true, value: children.length === 1 ? children[0] : { type: 'or', children } } + } + + private parseAnd (): Tagged { + const first = this.parseUnary() + if (!first.ok) return first + const children: Node[] = [first.value] + for (;;) { + const t = this.peek() + if (t === undefined || t.type === 'or' || t.type === 'rparen') break + if (t.type === 'and') this.i++ + const next = this.parseUnary() + if (!next.ok) return next + children.push(next.value) + } + return { ok: true, value: children.length === 1 ? children[0] : { type: 'and', children } } + } + + private parseUnary (): Tagged { + const t = this.peek() + if (t === undefined) { + const last = this.tokens[this.tokens.length - 1] + return err('unexpectedToken', 'Expected a filter term', last?.end ?? 0, last?.end ?? 0) + } + switch (t.type) { + case 'lparen': + case 'not': { + const negated = t.type === 'not' + if (negated) { + this.i++ + if (this.peek()?.type !== 'lparen') return err('unexpectedToken', 'Expected "("', t.end, t.end) + } + const open = this.tokens[this.i] + this.i++ + const inner = this.parseOr() + if (!inner.ok) return inner + const close = this.peek() + if (close?.type !== 'rparen') return err('unbalancedParenthesis', 'Missing ")"', open.pos, open.end) + this.i++ + return { ok: true, value: negated ? { type: 'not', child: inner.value } : inner.value } + } + case 'term': + this.i++ + return this.parseTerm(t) + case 'rparen': + return err('unbalancedParenthesis', 'Unmatched ")"', t.pos, t.end) + default: + return err('unexpectedToken', `Unexpected ${t.type.toUpperCase()}`, t.pos, t.end) + } + } + + private parseTerm (t: Extract): Tagged { + const wrap = (node: Node): Tagged => ({ ok: true, value: t.negated ? { type: 'not', child: node } : node }) + + if (t.field === undefined) { + const v = t.values[0] + if (v.text === '') return err('missingValue', 'Empty search text', t.pos, t.end) + return wrap({ type: 'text', value: v.text, quoted: v.quoted, pos: t.pos }) + } + + const name = t.field.name.toLowerCase() + if (name === 'has' || name === 'no') return this.parsePresence(t, name === 'has', wrap) + if (name === 'is') return this.parseIs(t, wrap) + + const spec = findField(this.schema, name) + if (spec === undefined) { + const known = [...this.schema.map((f) => f.name), ...RESERVED_FIELDS] + return err('unknownField', `Unknown field "${t.field.name}"`, t.field.pos, t.field.end, similarFieldNames(name, known)) + } + if (spec.type === 'presence') { + return err('invalidOperator', `"${spec.name}" can only be used with has: or no:`, t.pos, t.end) + } + const values: FieldValue[] = [] + for (const v of t.values) { + const parsed = parseFieldValue(spec, v) + if (!parsed.ok) return parsed + values.push(parsed.value) + } + return wrap({ type: 'field', field: spec, values, pos: t.pos }) + } + + private parsePresence ( + t: Extract, + present: boolean, + wrap: (n: Node) => Tagged + ): Tagged { + const nodes: Node[] = [] + for (const v of t.values) { + if (v.text === '') return err('missingValue', 'Expected a field name', v.pos, Math.max(v.end, v.pos)) + const spec = findField(this.schema, v.text) + if (spec === undefined) { + return err( + 'unknownField', + `Unknown field "${v.text}"`, + v.pos, + v.end, + similarFieldNames( + v.text, + this.schema.map((f) => f.name) + ) + ) + } + nodes.push({ type: 'presence', field: spec, present, pos: t.pos }) + } + return wrap(nodes.length === 1 ? nodes[0] : { type: 'or', children: nodes }) + } + + private parseIs (t: Extract, wrap: (n: Node) => Tagged): Tagged { + const nodes: Node[] = [] + for (const v of t.values) { + const state = v.text.toLowerCase() as IsState + if (!IS_VALUES.includes(state)) { + return err('unknownKeyword', `Unknown value "${v.text}" for is:`, v.pos, v.end, IS_VALUES) + } + nodes.push({ type: 'is', state, pos: t.pos }) + } + return wrap(nodes.length === 1 ? nodes[0] : { type: 'or', children: nodes }) + } +} + +const OPERATORS: CompareOp[] = ['>=', '<=', '>', '<'] + +function parseScalar (spec: FieldSpec, text: string, quoted: boolean, pos: number, end: number): Tagged { + switch (spec.type) { + case 'number': { + const value = text.trim() === '' ? NaN : Number(text) + if (!Number.isFinite(value)) return err('invalidNumber', `"${text}" is not a number`, pos, end) + return { ok: true, value: { kind: 'number', value } } + } + case 'date': { + const date = parseDateScalar(text) + if (date === undefined) { + return err('invalidDate', `"${text}" is not a date, use YYYY-MM-DD or @today-7d`, pos, end, ['@today', '@today-7d']) + } + return { ok: true, value: date } + } + case 'iteration': { + if (!quoted && text.startsWith('@')) { + const it = parseIterationScalar(text) + if (it === undefined) { + return err('unknownKeyword', `Unknown keyword "${text}"`, pos, end, ['@current', '@next', '@previous']) + } + return { ok: true, value: it } + } + return { ok: true, value: { kind: 'text', text, quoted } } + } + case 'user': + if (!quoted && text === '@me') return { ok: true, value: { kind: 'me' } } + return { ok: true, value: { kind: 'text', text: !quoted && text.startsWith('@') ? text.slice(1) : text, quoted } } + default: + return { ok: true, value: { kind: 'text', text, quoted } } + } +} + +/** + * Parses one comma separated value of `field:value` according to the field type. + * @public + */ +export function parseFieldValue (spec: FieldSpec, v: ValueToken): Tagged { + const ordered = ORDERED_TYPES.includes(spec.type) + let text = v.text + if (text === '') return err('missingValue', `Expected a value for "${spec.name}"`, v.pos, Math.max(v.end, v.pos + 1)) + + if (!v.quoted) { + const op = OPERATORS.find((o) => text.startsWith(o)) + if (op !== undefined) { + if (!ordered) { + return err('invalidOperator', `"${op}" cannot be used with "${spec.name}"`, v.pos, v.pos + op.length) + } + text = text.slice(op.length) + if (text === '') return err('missingValue', `Expected a value after "${op}"`, v.pos, v.end) + const scalar = parseScalar(spec, text, false, v.pos + op.length, v.end) + return scalar.ok ? { ok: true, value: { kind: 'compare', op, value: scalar.value } } : scalar + } + const dots = text.indexOf('..') + if (dots !== -1 && ordered) { + const left = text.slice(0, dots) + const right = text.slice(dots + 2) + const bound = (part: string, offset: number): Tagged => { + if (part === '' || part === '*') return { ok: true, value: undefined } + return parseScalar(spec, part, false, v.pos + offset, v.pos + offset + part.length) + } + if (right.includes('..')) return err('invalidRange', 'A range has exactly two bounds', v.pos, v.end) + const from = bound(left, 0) + if (!from.ok) return from + const to = bound(right, dots + 2) + if (!to.ok) return to + return { ok: true, value: { kind: 'range', from: from.value, to: to.value } } + } + } + const scalar = parseScalar(spec, text, v.quoted, v.pos, v.end) + return scalar.ok ? { ok: true, value: { kind: 'eq', value: scalar.value } } : scalar +} + +/** + * Parses a filter string into an AST. Never throws; errors carry the offending position. + * An empty string yields an empty AND that matches everything. + * @public + */ +export function parseFilter (input: string, schema: readonly FieldSpec[]): Result { + const tokens = tokenize(input) + if (!tokens.ok) return tokens + return new Parser(tokens.value, schema).parse() +} diff --git a/plugins/view-resources/src/filter/grammar/suggest.d.ts b/plugins/view-resources/src/filter/grammar/suggest.d.ts new file mode 100644 index 0000000000..c2a33b88d8 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/suggest.d.ts @@ -0,0 +1,44 @@ +import type { FieldSpec } from './types'; +/** + * @public + */ +export interface Suggestion { + kind: 'field' | 'value'; + label: string; + insert: string; +} +/** + * @public + */ +export interface SuggestionResult { + from: number; + to: number; + items: Suggestion[]; +} +/** + * Autocomplete suggestions for the term at the caret: field names (with `has`, `no`, `is`) when the + * term has no colon yet, otherwise the known values of the typed field. + * @public + */ +export declare function suggest(text: string, caret: number, schema: readonly FieldSpec[]): SuggestionResult; +/** + * Applies a suggestion to the input; returns the new text and caret position. + * @public + */ +export declare function applySuggestion(text: string, result: SuggestionResult, item: Suggestion): { + text: string; + caret: number; +}; +/** + * Adds `field:value` as one more AND condition to a filter string (the "click a value to filter" action). + * A term that is already there is not repeated; a filter with a top level OR is parenthesized first so + * that the new condition applies to the whole of it. + * @public + */ +export declare function appendTerm(query: string, field: string, value: string): string; +/** + * Joins two filter strings with AND; a part with a top level OR is parenthesized so that it keeps its meaning. + * @public + */ +export declare function joinAnd(a: string, b: string): string; +//# sourceMappingURL=suggest.d.ts.map \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/suggest.d.ts.map b/plugins/view-resources/src/filter/grammar/suggest.d.ts.map new file mode 100644 index 0000000000..378a29e523 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/suggest.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"suggest.d.ts","sourceRoot":"","sources":["suggest.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAS,MAAM,SAAS,CAAA;AAE/C;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;IAEvB,KAAK,EAAE,MAAM,CAAA;IAEb,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAA;IACZ,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,UAAU,EAAE,CAAA;CACpB;AA8CD;;;;GAIG;AACH,wBAAgB,OAAO,CAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,SAAS,EAAE,GAAG,gBAAgB,CAqDpG;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,UAAU,GACf;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAGjC;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAW/E;AAED;;;GAGG;AACH,wBAAgB,OAAO,CAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAUrD"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/suggest.ts b/plugins/view-resources/src/filter/grammar/suggest.ts new file mode 100644 index 0000000000..26b0efc5f9 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/suggest.ts @@ -0,0 +1,180 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { findField, IS_VALUES } from './parser' +import { tokenize } from './tokenizer' +import type { FieldSpec, Token } from './types' + +/** + * @public + */ +export interface Suggestion { + kind: 'field' | 'value' + // Shown in the list + label: string + // Replaces the typed fragment [from, to) of the input + insert: string +} + +/** + * @public + */ +export interface SuggestionResult { + from: number + to: number + items: Suggestion[] +} + +const MAX_ITEMS = 12 +const PSEUDO_FIELDS = ['has', 'no', 'is'] + +// Start of the term the caret is in: after the last whitespace or "(" that is not inside quotes +function termStart (text: string, caret: number): number { + let start = 0 + let quoted = false + for (let i = 0; i < caret; i++) { + const c = text[i] + if (c === '"' && text[i - 1] !== '\\') quoted = !quoted + else if (!quoted && (c === ' ' || c === '\t' || c === '\n' || c === '(' || c === ')')) start = i + 1 + } + return start +} + +// OR outside of any parentheses +function hasTopLevelOr (tokens: Token[]): boolean { + let depth = 0 + for (const t of tokens) { + if (t.type === 'lparen') depth++ + else if (t.type === 'rparen') depth-- + else if (t.type === 'or' && depth === 0) return true + } + return false +} + +function quoteIfNeeded (value: string): string { + return /[\s,:()"]/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value +} + +function valueSuggestions (field: FieldSpec): string[] { + const options = (field.options ?? []).map((o) => o.name) + switch (field.type) { + case 'user': + return ['@me', ...options] + case 'date': + return ['@today', '@today-7d', '@today+7d'] + case 'iteration': + return ['@current', '@next', '@previous', ...options] + default: + return options + } +} + +/** + * Autocomplete suggestions for the term at the caret: field names (with `has`, `no`, `is`) when the + * term has no colon yet, otherwise the known values of the typed field. + * @public + */ +export function suggest (text: string, caret: number, schema: readonly FieldSpec[]): SuggestionResult { + const start = termStart(text, caret) + let from = start + let word = text.slice(start, caret) + if (word.startsWith('-')) { + from++ + word = word.slice(1) + } + + const colon = word.indexOf(':') + if (colon === -1) { + const typed = word.toLowerCase() + const names = [...schema.map((f) => f.name), ...PSEUDO_FIELDS] + const items = names + .filter((n) => n.startsWith(typed) && n !== typed) + .slice(0, MAX_ITEMS) + .map((n): Suggestion => ({ kind: 'field', label: n, insert: `${n}:` })) + return { from, to: caret, items } + } + + const name = word.slice(0, colon).toLowerCase() + from += colon + 1 + let rest = word.slice(colon + 1) + // Only the value after the last comma outside quotes is completed + let comma = -1 + let quoted = false + for (let i = 0; i < rest.length; i++) { + if (rest[i] === '"') quoted = !quoted + else if (rest[i] === ',' && !quoted) comma = i + } + if (comma !== -1) { + from += comma + 1 + rest = rest.slice(comma + 1) + } + const opMatch = /^(>=|<=|>|<)/.exec(rest) + if (opMatch !== null) { + from += opMatch[0].length + rest = rest.slice(opMatch[0].length) + } + const typed = rest.replace(/^"/, '').toLowerCase() + + let candidates: string[] + if (name === 'has' || name === 'no') candidates = schema.map((f) => f.name) + else if (name === 'is') candidates = IS_VALUES + else { + const field = findField(schema, name) + candidates = field === undefined ? [] : valueSuggestions(field) + } + const items = [...new Set(candidates)] + .filter((c) => c.toLowerCase().startsWith(typed) && c.toLowerCase() !== typed) + .slice(0, MAX_ITEMS) + .map((c): Suggestion => ({ kind: 'value', label: c, insert: quoteIfNeeded(c) })) + return { from, to: caret, items } +} + +/** + * Applies a suggestion to the input; returns the new text and caret position. + * @public + */ +export function applySuggestion ( + text: string, + result: SuggestionResult, + item: Suggestion +): { text: string, caret: number } { + const next = text.slice(0, result.from) + item.insert + text.slice(result.to) + return { text: next, caret: result.from + item.insert.length } +} + +/** + * Adds `field:value` as one more AND condition to a filter string (the "click a value to filter" action). + * A term that is already there is not repeated; a filter with a top level OR is parenthesized first so + * that the new condition applies to the whole of it. + * @public + */ +export function appendTerm (query: string, field: string, value: string): string { + const term = `${field}:${quoteIfNeeded(value)}` + const trimmed = query.trim() + if (trimmed === '') return term + const tokens = tokenize(trimmed) + if (tokens.ok) { + const present = tokens.value.some((t) => t.type === 'term' && !t.negated && trimmed.slice(t.pos, t.end) === term) + if (present) return trimmed + if (hasTopLevelOr(tokens.value)) return `(${trimmed}) ${term}` + } + return `${trimmed} ${term}` +} + +/** + * Joins two filter strings with AND; a part with a top level OR is parenthesized so that it keeps its meaning. + * @public + */ +export function joinAnd (a: string, b: string): string { + const left = a.trim() + const right = b.trim() + if (left === '') return right + if (right === '') return left + const wrap = (text: string): string => { + const tokens = tokenize(text) + return tokens.ok && hasTopLevelOr(tokens.value) ? `(${text})` : text + } + return `${wrap(left)} ${wrap(right)}` +} diff --git a/plugins/view-resources/src/filter/grammar/tokenizer.d.ts b/plugins/view-resources/src/filter/grammar/tokenizer.d.ts new file mode 100644 index 0000000000..4bd902c8f5 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/tokenizer.d.ts @@ -0,0 +1,11 @@ +import type { Result, Token } from './types'; +/** + * Splits a filter string into tokens. Never throws: a malformed string yields `{ ok: false, error }`. + * + * - `field:a,b` is one term with several comma separated values, `-field:a` is negated + * - double quotes keep spaces, commas, colons and parentheses inside a value; `\"` is a quote + * - `AND`, `OR` (upper case, standalone) and parentheses are operators + * @public + */ +export declare function tokenize(input: string): Result; +//# sourceMappingURL=tokenizer.d.ts.map \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/tokenizer.d.ts.map b/plugins/view-resources/src/filter/grammar/tokenizer.d.ts.map new file mode 100644 index 0000000000..81394ca7ca --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/tokenizer.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"tokenizer.d.ts","sourceRoot":"","sources":["tokenizer.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAc,MAAM,EAAE,KAAK,EAAc,MAAM,SAAS,CAAA;AASpE;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC,KAAK,EAAE,CAAC,CA6FxD"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/tokenizer.ts b/plugins/view-resources/src/filter/grammar/tokenizer.ts new file mode 100644 index 0000000000..7fc6cf95d3 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/tokenizer.ts @@ -0,0 +1,120 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { ParseError, Result, Token, ValueToken } from './types' + +// Letters of any script: field names of user-defined fields come from their labels +const FIELD_NAME = /^\p{L}[\p{L}\p{N}_.-]*$/u + +function isSpace (c: string): boolean { + return c === ' ' || c === '\t' || c === '\n' || c === '\r' +} + +/** + * Splits a filter string into tokens. Never throws: a malformed string yields `{ ok: false, error }`. + * + * - `field:a,b` is one term with several comma separated values, `-field:a` is negated + * - double quotes keep spaces, commas, colons and parentheses inside a value; `\"` is a quote + * - `AND`, `OR` (upper case, standalone) and parentheses are operators + * @public + */ +export function tokenize (input: string): Result { + const tokens: Token[] = [] + const n = input.length + let i = 0 + + while (i < n) { + const c = input[i] + if (isSpace(c)) { + i++ + continue + } + if (c === '(') { + tokens.push({ type: 'lparen', pos: i, end: i + 1 }) + i++ + continue + } + if (c === ')') { + tokens.push({ type: 'rparen', pos: i, end: i + 1 }) + i++ + continue + } + if (c === '-' && input[i + 1] === '(') { + tokens.push({ type: 'not', pos: i, end: i + 1 }) + i++ + continue + } + + const start = i + let negated = false + if (c === '-' && i + 1 < n && !isSpace(input[i + 1])) { + negated = true + i++ + } + + let field: { name: string, pos: number, end: number } | undefined + const values: ValueToken[] = [] + let cur: ValueToken = { text: '', quoted: false, pos: i, end: i } + const textStart = i + + while (i < n) { + const ch = input[i] + if (isSpace(ch) || ch === ')') break + if (ch === '"') { + let j = i + 1 + let text = '' + let closed = false + while (j < n) { + if (input[j] === '\\' && input[j + 1] === '"') { + text += '"' + j += 2 + continue + } + if (input[j] === '"') { + closed = true + break + } + text += input[j] + j++ + } + if (!closed) return fail('unterminatedQuote', 'Unterminated quote', i, n) + cur.text += text + cur.quoted = true + i = j + 1 + continue + } + if (ch === ':' && field === undefined && !cur.quoted && FIELD_NAME.test(cur.text)) { + field = { name: cur.text, pos: textStart, end: i } + cur = { text: '', quoted: false, pos: i + 1, end: i + 1 } + i++ + continue + } + if (ch === ',' && field !== undefined) { + cur.end = i + values.push(cur) + cur = { text: '', quoted: false, pos: i + 1, end: i + 1 } + i++ + continue + } + cur.text += ch + i++ + } + cur.end = i + values.push(cur) + + const raw = input.slice(start, i) + if (field === undefined && !negated && !cur.quoted && (raw === 'AND' || raw === 'OR')) { + tokens.push({ type: raw === 'AND' ? 'and' : 'or', pos: start, end: i }) + continue + } + tokens.push({ type: 'term', pos: start, end: i, negated, field, values }) + } + + return { ok: true, value: tokens } +} + +function fail (code: ParseError['code'], message: string, pos: number, end: number): Result { + return { ok: false, error: { code, message, pos, end } } +} diff --git a/plugins/view-resources/src/filter/grammar/types.d.ts b/plugins/view-resources/src/filter/grammar/types.d.ts new file mode 100644 index 0000000000..84e1b800f4 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/types.d.ts @@ -0,0 +1,202 @@ +/** + * Kind of values a field holds. It decides which operators and value syntaxes are valid. + * @public + */ +export type FieldType = 'text' | 'number' | 'date' | 'select' | 'multi' | 'user' | 'iteration' | 'presence'; +/** + * @public + */ +export interface FieldOption { + id: string | number; + name: string; +} +/** + * Description of a filterable field. The schema is supplied by the host view. + * @public + */ +export interface FieldSpec { + name: string; + label: string; + type: FieldType; + source: 'attribute' | 'custom'; + key: string; + options?: FieldOption[]; + read?: (doc: any) => unknown; + resolveDocIds?: (optionIds: Array) => string[]; + presenceQuery?: (present: boolean) => Record; + dependsOn?: string[]; + clientOnly?: boolean; +} +/** + * Where an error was found: character offsets into the filter string, end exclusive. + * @public + */ +export type ParseErrorCode = 'unterminatedQuote' | 'unbalancedParenthesis' | 'unexpectedToken' | 'unknownField' | 'missingValue' | 'invalidOperator' | 'invalidValue' | 'invalidDate' | 'invalidNumber' | 'invalidRange' | 'unknownKeyword'; +/** + * @public + */ +export interface ParseError { + code: ParseErrorCode; + message: string; + pos: number; + end: number; + hints?: string[]; +} +/** + * @public + */ +export type Result = { + ok: true; + value: T; +} | { + ok: false; + error: ParseError; +}; +/** + * One comma separated value of a `field:value` term. + * @public + */ +export interface ValueToken { + text: string; + quoted: boolean; + pos: number; + end: number; +} +/** + * @public + */ +export type Token = { + type: 'lparen'; + pos: number; + end: number; +} | { + type: 'rparen'; + pos: number; + end: number; +} | { + type: 'and'; + pos: number; + end: number; +} | { + type: 'or'; + pos: number; + end: number; +} | { + type: 'not'; + pos: number; + end: number; +} | { + type: 'term'; + pos: number; + end: number; + negated: boolean; + field?: { + name: string; + pos: number; + end: number; + }; + values: ValueToken[]; +}; +/** + * Calendar unit of a relative date. + * @public + */ +export type DateUnit = 'd' | 'w' | 'm' | 'y'; +/** + * @public + */ +export type Scalar = { + kind: 'text'; + text: string; + quoted: boolean; +} | { + kind: 'me'; +} | { + kind: 'number'; + value: number; +} | { + kind: 'date'; + abs?: number; + amount: number; + unit: DateUnit; +} | { + kind: 'iteration'; + keyword: 'current' | 'next' | 'previous'; + offset: number; +}; +/** + * @public + */ +export type CompareOp = '>' | '>=' | '<' | '<='; +/** + * @public + */ +export type FieldValue = { + kind: 'eq'; + value: Scalar; +} | { + kind: 'compare'; + op: CompareOp; + value: Scalar; +} | { + kind: 'range'; + from?: Scalar; + to?: Scalar; +}; +/** + * @public + */ +export type IsState = 'open' | 'closed' | 'issue' | 'sub-issue'; +/** + * @public + */ +export type Node = { + type: 'and'; + children: Node[]; +} | { + type: 'or'; + children: Node[]; +} | { + type: 'not'; + child: Node; +} | { + type: 'text'; + value: string; + quoted: boolean; + pos: number; +} | { + type: 'field'; + field: FieldSpec; + values: FieldValue[]; + pos: number; +} | { + type: 'presence'; + field: FieldSpec; + present: boolean; + pos: number; +} | { + type: 'is'; + state: IsState; + pos: number; +}; +/** + * Environment of compilation and evaluation. + * @public + */ +export interface FilterContext { + now: number; + me?: string; + iterations?: (fieldKey: string) => IterationInfo[]; + closedStatuses?: ReadonlySet; + noParentId?: string; +} +/** + * @public + */ +export interface IterationInfo { + id: string; + title: string; + start: number; + end: number; +} +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/types.d.ts.map b/plugins/view-resources/src/filter/grammar/types.d.ts.map new file mode 100644 index 0000000000..2473c968e3 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AASA;;;GAGG;AACH,MAAM,MAAM,SAAS,GACjB,MAAM,GACN,QAAQ,GACR,MAAM,GACN,QAAQ,GACR,OAAO,GACP,MAAM,GACN,WAAW,GACX,UAAU,CAAA;AAEd;;GAEG;AACH,MAAM,WAAW,WAAW;IAE1B,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;IAEnB,IAAI,EAAE,MAAM,CAAA;CACb;AAED;;;GAGG;AACH,MAAM,WAAW,SAAS;IAExB,IAAI,EAAE,MAAM,CAAA;IAEZ,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,SAAS,CAAA;IAEf,MAAM,EAAE,WAAW,GAAG,QAAQ,CAAA;IAE9B,GAAG,EAAE,MAAM,CAAA;IAEX,OAAO,CAAC,EAAE,WAAW,EAAE,CAAA;IAEvB,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAA;IAE5B,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,KAAK,MAAM,EAAE,CAAA;IAE/D,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAEzD,SAAS,CAAC,EAAE,MAAM,EAAE,CAAA;IAEpB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED;;;GAGG;AACH,MAAM,MAAM,cAAc,GACtB,mBAAmB,GACnB,uBAAuB,GACvB,iBAAiB,GACjB,cAAc,GACd,cAAc,GACd,iBAAiB,GACjB,cAAc,GACd,aAAa,GACb,eAAe,GACf,cAAc,GACd,gBAAgB,CAAA;AAEpB;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,cAAc,CAAA;IACpB,OAAO,EAAE,MAAM,CAAA;IAEf,GAAG,EAAE,MAAM,CAAA;IAEX,GAAG,EAAE,MAAM,CAAA;IAEX,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB;AAED;;GAEG;AACH,MAAM,MAAM,MAAM,CAAC,CAAC,IAAI;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,CAAA;AAIjF;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,OAAO,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;CACZ;AAED;;GAEG;AACH,MAAM,MAAM,KAAK,GACb;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAExC;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACzC;IACA,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,OAAO,EAAE,OAAO,CAAA;IAEhB,KAAK,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAA;IAClD,MAAM,EAAE,UAAU,EAAE,CAAA;CACrB,CAAA;AAIH;;;GAGG;AACH,MAAM,MAAM,QAAQ,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;AAE5C;;GAEG;AACH,MAAM,MAAM,MAAM,GACd;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,GACd;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAEjC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAC9D;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,OAAO,EAAE,SAAS,GAAG,MAAM,GAAG,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAEnF;;GAEG;AACH,MAAM,MAAM,SAAS,GAAG,GAAG,GAAG,IAAI,GAAG,GAAG,GAAG,IAAI,CAAA;AAE/C;;GAEG;AACH,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,EAAE,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAEjD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAIjD;;GAEG;AACH,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,QAAQ,GAAG,OAAO,GAAG,WAAW,CAAA;AAE/D;;GAEG;AACH,MAAM,MAAM,IAAI,GACZ;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,QAAQ,EAAE,IAAI,EAAE,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,IAAI,EAAE,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,IAAI,CAAA;CAAE,GAE5B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAE7D;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,UAAU,EAAE,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAEtE;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACrE;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAA;AAE/C;;;GAGG;AACH,MAAM,WAAW,aAAa;IAE5B,GAAG,EAAE,MAAM,CAAA;IAEX,EAAE,CAAC,EAAE,MAAM,CAAA;IAEX,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,aAAa,EAAE,CAAA;IAElD,cAAc,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;IAEpC,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IAEb,KAAK,EAAE,MAAM,CAAA;IAEb,GAAG,EAAE,MAAM,CAAA;CACZ"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/types.ts b/plugins/view-resources/src/filter/grammar/types.ts new file mode 100644 index 0000000000..b35db1dfe1 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/types.ts @@ -0,0 +1,213 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Shared types of the GitHub-Projects style filter grammar (plan D3). The module is pure: +// it has no platform or UI imports so that the tokenizer, parser, compiler and evaluator +// can be unit tested and reused by any view. + +/** + * Kind of values a field holds. It decides which operators and value syntaxes are valid. + * @public + */ +export type FieldType = + | 'text' // contains-match, `*` wildcards + | 'number' // =, comparison, ranges + | 'date' // =, comparison, ranges, `@today[-7d]` + | 'select' // one of the options, matched by label + | 'multi' // any of the options (labels, multi-select) + | 'user' // like select, plus `@me` + | 'iteration' // like select, plus `@current`, `@next`, `@previous` and arithmetic + | 'presence' // only usable with has:/no: + +/** + * @public + */ +export interface FieldOption { + // Stored value (ref, enum value or option id) + id: string | number + // Label the user types in a filter + name: string +} + +/** + * Description of a filterable field. The schema is supplied by the host view. + * @public + */ +export interface FieldSpec { + // Name as typed in the filter, lowercase without spaces (`story-points`) + name: string + // Human readable label, for suggestions + label: string + type: FieldType + // Where the value lives: an attribute of the document or an entry of `customFields` + source: 'attribute' | 'custom' + // Attribute name or custom field key + key: string + // Choices of select / multi / user / iteration fields + options?: FieldOption[] + // Reads the value from a document. Defaults to the attribute / custom field named by `key` + read?: (doc: any) => unknown + // Document ids that have any of the option ids, for fields the server cannot index directly (labels) + resolveDocIds?: (optionIds: Array) => string[] + // Server query fragment for has:/no: on fields that are not plain attributes + presenceQuery?: (present: boolean) => Record + // Attributes `read` needs besides `key`, so that a client scan loads them + dependsOn?: string[] + // Never evaluated on the server (e.g. values that only exist on the client) + clientOnly?: boolean +} + +/** + * Where an error was found: character offsets into the filter string, end exclusive. + * @public + */ +export type ParseErrorCode = + | 'unterminatedQuote' + | 'unbalancedParenthesis' + | 'unexpectedToken' + | 'unknownField' + | 'missingValue' + | 'invalidOperator' + | 'invalidValue' + | 'invalidDate' + | 'invalidNumber' + | 'invalidRange' + | 'unknownKeyword' + +/** + * @public + */ +export interface ParseError { + code: ParseErrorCode + message: string + // Offset of the first offending character + pos: number + // Offset after the last offending character + end: number + // Suggestions for the offending token, e.g. known field names + hints?: string[] +} + +/** + * @public + */ +export type Result = { ok: true, value: T } | { ok: false, error: ParseError } + +// ---- tokens ---- + +/** + * One comma separated value of a `field:value` term. + * @public + */ +export interface ValueToken { + text: string + quoted: boolean + pos: number + end: number +} + +/** + * @public + */ +export type Token = + | { type: 'lparen', pos: number, end: number } + | { type: 'rparen', pos: number, end: number } + | { type: 'and', pos: number, end: number } + | { type: 'or', pos: number, end: number } + // `-(`: negation of a group + | { type: 'not', pos: number, end: number } + | { + type: 'term' + pos: number + end: number + negated: boolean + // Absent for free text + field?: { name: string, pos: number, end: number } + values: ValueToken[] + } + +// ---- values ---- + +/** + * Calendar unit of a relative date. + * @public + */ +export type DateUnit = 'd' | 'w' | 'm' | 'y' + +/** + * @public + */ +export type Scalar = + | { kind: 'text', text: string, quoted: boolean } + | { kind: 'me' } + | { kind: 'number', value: number } + // Absolute day (`abs`, start of the day) or relative to `@today` + | { kind: 'date', abs?: number, amount: number, unit: DateUnit } + | { kind: 'iteration', keyword: 'current' | 'next' | 'previous', offset: number } + +/** + * @public + */ +export type CompareOp = '>' | '>=' | '<' | '<=' + +/** + * @public + */ +export type FieldValue = + | { kind: 'eq', value: Scalar } + | { kind: 'compare', op: CompareOp, value: Scalar } + // Inclusive; a missing bound is the `*` wildcard + | { kind: 'range', from?: Scalar, to?: Scalar } + +// ---- AST ---- + +/** + * @public + */ +export type IsState = 'open' | 'closed' | 'issue' | 'sub-issue' + +/** + * @public + */ +export type Node = + | { type: 'and', children: Node[] } + | { type: 'or', children: Node[] } + | { type: 'not', child: Node } + // Free text, matches the title + | { type: 'text', value: string, quoted: boolean, pos: number } + // `field:a,b` matches when ANY value matches + | { type: 'field', field: FieldSpec, values: FieldValue[], pos: number } + // has:field (present = true) or no:field + | { type: 'presence', field: FieldSpec, present: boolean, pos: number } + | { type: 'is', state: IsState, pos: number } + +/** + * Environment of compilation and evaluation. + * @public + */ +export interface FilterContext { + // Timestamp that `@today` refers to + now: number + // Id of the current user for `@me` + me?: string + // Iterations of an iteration field (by field key); resolves @current, @next, @previous + iterations?: (fieldKey: string) => IterationInfo[] + // Option ids of statuses that count as closed (done or cancelled), for is:open / is:closed + closedStatuses?: ReadonlySet + // Value of `Issue.attachedTo` of an issue without a parent, for is:sub-issue + noParentId?: string +} + +/** + * @public + */ +export interface IterationInfo { + id: string + title: string + // Start of the first day + start: number + // Last millisecond of the last day + end: number +} diff --git a/plugins/view-resources/src/filter/grammar/values.d.ts b/plugins/view-resources/src/filter/grammar/values.d.ts new file mode 100644 index 0000000000..1ae4b26548 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/values.d.ts @@ -0,0 +1,44 @@ +import type { IterationInfo, Scalar } from './types'; +/** Start of the local calendar day containing the timestamp. */ +export declare function startOfDay(ts: number): number; +/** Last millisecond of the local calendar day containing the timestamp. */ +export declare function endOfDay(ts: number): number; +/** + * Parses `YYYY-MM-DD`, `@today` or `@today[+-]N(d|w|m|y)`. Returns undefined for anything else, + * including calendar dates that do not exist (2024-02-31). + * @public + */ +export declare function parseDateScalar(text: string): Scalar | undefined; +/** + * Start of the day a date scalar refers to. + * @public + */ +export declare function resolveDate(scalar: Extract, now: number): number; +/** + * Parses `@current`, `@next`, `@previous` with optional `+N` / `-N` arithmetic. + * @public + */ +export declare function parseIterationScalar(text: string): Scalar | undefined; +/** + * Resolves an iteration keyword against the iterations of a field. `@current` is the iteration that + * contains `now`, `@next` and `@previous` its neighbours (in a gap between iterations the closest + * upcoming / finished one), `+N` / `-N` steps over further iterations. Undefined when there is none. + * @public + */ +export declare function resolveIteration(scalar: Extract, iterations: readonly IterationInfo[], now: number): IterationInfo | undefined; +/** + * Case-insensitive match of a label against a pattern where `*` matches any run of characters. + * Without a wildcard the whole label has to be equal. + * @public + */ +export declare function matchGlob(pattern: string, label: string): boolean; +/** + * Case-insensitive substring match where `*` matches any run of characters. + * @public + */ +export declare function containsGlob(pattern: string, text: string): boolean; +//# sourceMappingURL=values.d.ts.map \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/values.d.ts.map b/plugins/view-resources/src/filter/grammar/values.d.ts.map new file mode 100644 index 0000000000..ec18692935 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/values.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"values.d.ts","sourceRoot":"","sources":["values.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAY,aAAa,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAI9D,gEAAgE;AAChE,wBAAgB,UAAU,CAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED,2EAA2E;AAC3E,wBAAgB,QAAQ,CAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAE5C;AAMD;;;;GAIG;AACH,wBAAgB,eAAe,CAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAejE;AAgCD;;;GAGG;AACH,wBAAgB,WAAW,CAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAG3F;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAKtE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,CAAC,EAC9C,UAAU,EAAE,SAAS,aAAa,EAAE,EACpC,GAAG,EAAE,MAAM,GACV,aAAa,GAAG,SAAS,CAkB3B;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAgBlE;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAEpE"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/values.ts b/plugins/view-resources/src/filter/grammar/values.ts new file mode 100644 index 0000000000..effdce4cc5 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/values.ts @@ -0,0 +1,155 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { DateUnit, IterationInfo, Scalar } from './types' + +// Pure helpers for the value syntaxes of the grammar: dates, iterations and wildcards. + +/** Start of the local calendar day containing the timestamp. */ +export function startOfDay (ts: number): number { + return new Date(ts).setHours(0, 0, 0, 0) +} + +/** Last millisecond of the local calendar day containing the timestamp. */ +export function endOfDay (ts: number): number { + return new Date(ts).setHours(23, 59, 59, 999) +} + +const ABSOLUTE_DATE = /^(\d{4})-(\d{2})-(\d{2})$/ +const RELATIVE_DATE = /^@today(?:([+-])(\d+)([dwmy]))?$/ +const ITERATION = /^@(current|next|previous)(?:([+-])(\d+))?$/ + +/** + * Parses `YYYY-MM-DD`, `@today` or `@today[+-]N(d|w|m|y)`. Returns undefined for anything else, + * including calendar dates that do not exist (2024-02-31). + * @public + */ +export function parseDateScalar (text: string): Scalar | undefined { + const abs = ABSOLUTE_DATE.exec(text) + if (abs !== null) { + const [y, m, d] = [Number(abs[1]), Number(abs[2]), Number(abs[3])] + const date = new Date(y, m - 1, d) + if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) return undefined + return { kind: 'date', abs: date.getTime(), amount: 0, unit: 'd' } + } + const rel = RELATIVE_DATE.exec(text) + if (rel !== null) { + if (rel[1] === undefined) return { kind: 'date', amount: 0, unit: 'd' } + const amount = Number(rel[2]) * (rel[1] === '-' ? -1 : 1) + return { kind: 'date', amount, unit: rel[3] as DateUnit } + } + return undefined +} + +function shiftDate (base: number, amount: number, unit: DateUnit): number { + const date = new Date(base) + switch (unit) { + case 'd': + date.setDate(date.getDate() + amount) + break + case 'w': + date.setDate(date.getDate() + amount * 7) + break + case 'm': { + // Clamp to the end of a shorter month instead of overflowing into the next one + const day = date.getDate() + date.setDate(1) + date.setMonth(date.getMonth() + amount) + const last = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate() + date.setDate(Math.min(day, last)) + break + } + case 'y': { + const day = date.getDate() + date.setDate(1) + date.setFullYear(date.getFullYear() + amount) + const last = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate() + date.setDate(Math.min(day, last)) + break + } + } + return date.getTime() +} + +/** + * Start of the day a date scalar refers to. + * @public + */ +export function resolveDate (scalar: Extract, now: number): number { + if (scalar.abs !== undefined) return scalar.abs + return startOfDay(shiftDate(startOfDay(now), scalar.amount, scalar.unit)) +} + +/** + * Parses `@current`, `@next`, `@previous` with optional `+N` / `-N` arithmetic. + * @public + */ +export function parseIterationScalar (text: string): Scalar | undefined { + const m = ITERATION.exec(text) + if (m === null) return undefined + const offset = m[2] === undefined ? 0 : Number(m[3]) * (m[2] === '-' ? -1 : 1) + return { kind: 'iteration', keyword: m[1] as 'current' | 'next' | 'previous', offset } +} + +/** + * Resolves an iteration keyword against the iterations of a field. `@current` is the iteration that + * contains `now`, `@next` and `@previous` its neighbours (in a gap between iterations the closest + * upcoming / finished one), `+N` / `-N` steps over further iterations. Undefined when there is none. + * @public + */ +export function resolveIteration ( + scalar: Extract, + iterations: readonly IterationInfo[], + now: number +): IterationInfo | undefined { + const sorted = [...iterations].sort((a, b) => a.start - b.start) + const current = sorted.findIndex((it) => it.start <= now && now <= it.end) + let base: number + if (current >= 0) { + base = scalar.keyword === 'current' ? current : scalar.keyword === 'next' ? current + 1 : current - 1 + } else if (scalar.keyword === 'current') { + return undefined + } else if (scalar.keyword === 'next') { + base = sorted.findIndex((it) => it.start > now) + } else { + base = -1 + sorted.forEach((it, i) => { + if (it.end < now) base = i + }) + } + if (base < 0) return undefined + return sorted[base + scalar.offset] +} + +/** + * Case-insensitive match of a label against a pattern where `*` matches any run of characters. + * Without a wildcard the whole label has to be equal. + * @public + */ +export function matchGlob (pattern: string, label: string): boolean { + const p = pattern.toLowerCase() + const l = label.toLowerCase() + if (!p.includes('*')) return p === l + const parts = p.split('*') + let pos = 0 + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + if (part === '') continue + const idx = l.indexOf(part, pos) + if (idx === -1) return false + if (i === 0 && idx !== 0) return false + pos = idx + part.length + } + const last = parts[parts.length - 1] + return last === '' || l.endsWith(last) +} + +/** + * Case-insensitive substring match where `*` matches any run of characters. + * @public + */ +export function containsGlob (pattern: string, text: string): boolean { + return matchGlob(`*${pattern}*`, text) +} diff --git a/plugins/view-resources/src/index.ts b/plugins/view-resources/src/index.ts index 6e1cee75c6..90c464b1a3 100644 --- a/plugins/view-resources/src/index.ts +++ b/plugins/view-resources/src/index.ts @@ -68,6 +68,7 @@ import ReadOnlyNotification from './components/ReadOnlyNotification.svelte' import RolePresenter from './components/RolePresenter.svelte' import SearchSelector from './components/SearchSelector.svelte' import SavedViewBar from './components/view/SavedViewBar.svelte' +import FilterQueryBar from './components/filter/FilterQueryBar.svelte' import SpaceHeader from './components/SpaceHeader.svelte' import SpacePresenter from './components/SpacePresenter.svelte' import SpaceRefPresenter from './components/SpaceRefPresenter.svelte' @@ -233,6 +234,7 @@ export { export * from './viewOptions' export * from './clientViewExtension' export * from './savedViews' +export * as filterGrammar from './filter/grammar' export * from './viewletContextStore' export { getViewletSpecialActions } from './viewletUtils' export { copyMarkdown } from './actionImpl' @@ -266,6 +268,7 @@ export { SortableList, SortableListItem, SavedViewBar, + FilterQueryBar, SpaceHeader, SpacePresenter, StringEditor, diff --git a/plugins/view-resources/src/plugin.ts b/plugins/view-resources/src/plugin.ts index 064e2eb789..0ef285ede8 100644 --- a/plugins/view-resources/src/plugin.ts +++ b/plugins/view-resources/src/plugin.ts @@ -126,7 +126,22 @@ export default mergeIds(viewId, typedView, { SavedViewDiscard: '' as IntlString, SavedViewUnsaved: '' as IntlString, SavedViewLayoutTable: '' as IntlString, - SavedViewLayoutTo: '' as IntlString + SavedViewLayoutTo: '' as IntlString, + + FilterQueryPlaceholder: '' as IntlString, + FilterQueryClear: '' as IntlString, + FilterQueryDidYouMean: '' as IntlString, + FilterQueryErrorQuote: '' as IntlString, + FilterQueryErrorParenthesis: '' as IntlString, + FilterQueryErrorUnexpected: '' as IntlString, + FilterQueryErrorUnknownField: '' as IntlString, + FilterQueryErrorMissingValue: '' as IntlString, + FilterQueryErrorOperator: '' as IntlString, + FilterQueryErrorValue: '' as IntlString, + FilterQueryErrorDate: '' as IntlString, + FilterQueryErrorNumber: '' as IntlString, + FilterQueryErrorRange: '' as IntlString, + FilterQueryErrorKeyword: '' as IntlString }, function: { CreateDocMiddleware: '' as Resource, diff --git a/plugins/view-resources/src/savedViews.ts b/plugins/view-resources/src/savedViews.ts index b42d5c233c..1e3d57584c 100644 --- a/plugins/view-resources/src/savedViews.ts +++ b/plugins/view-resources/src/savedViews.ts @@ -25,6 +25,8 @@ export interface ViewConfigLayer { config?: ViewColumns // JSON of host-specific filter state extra?: string + // GitHub-style filter string + filterQuery?: string } export interface EffectiveViewConfig { @@ -33,6 +35,7 @@ export interface EffectiveViewConfig { viewOptions?: ViewOptions config?: ViewColumns extra?: string + filterQuery: string } export interface EffectiveViewConfigParams { @@ -84,7 +87,8 @@ export function getEffectiveViewConfig (params: EffectiveViewConfigParams): Effe filters: local?.filters ?? saved?.filters ?? defaults?.filters ?? EMPTY_FILTERS, viewOptions, config: hasColumns(config) ? config : undefined, - extra: local?.extra ?? saved?.extra ?? defaults?.extra + extra: local?.extra ?? saved?.extra ?? defaults?.extra, + filterQuery: local?.filterQuery ?? saved?.filterQuery ?? defaults?.filterQuery ?? '' } } @@ -94,7 +98,9 @@ function normalize (layer: ViewConfigLayer): Required Date: Sat, 3 Oct 2026 16:29:01 +0530 Subject: [PATCH 19/32] feat(tracker): spreadsheet-style cell editing, paste, fill and undo in the table --- models/tracker/src/viewlets.ts | 15 +- plugins/tracker-assets/lang/cs.json | 4 + plugins/tracker-assets/lang/de.json | 4 + plugins/tracker-assets/lang/en.json | 4 + plugins/tracker-assets/lang/es.json | 4 + plugins/tracker-assets/lang/fr.json | 4 + plugins/tracker-assets/lang/it.json | 4 + plugins/tracker-assets/lang/ja.json | 4 + plugins/tracker-assets/lang/ko.json | 4 + plugins/tracker-assets/lang/pl.json | 4 + plugins/tracker-assets/lang/pt-br.json | 4 + plugins/tracker-assets/lang/pt.json | 4 + plugins/tracker-assets/lang/ru.json | 4 + plugins/tracker-assets/lang/tr.json | 4 + plugins/tracker-assets/lang/zh.json | 4 + .../src/__mocks__/view-resources.js | 8 +- .../src/bulkEdit/__tests__/issueCells.test.ts | 272 +++++++ .../src/bulkEdit/issueCells.ts | 331 ++++++++ .../src/components/issues/IssuesView.svelte | 92 ++- plugins/tracker-resources/src/plugin.ts | 4 + plugins/view-assets/lang/cs.json | 7 + plugins/view-assets/lang/de.json | 7 + plugins/view-assets/lang/en.json | 7 + plugins/view-assets/lang/es.json | 7 + plugins/view-assets/lang/fr.json | 7 + plugins/view-assets/lang/it.json | 7 + plugins/view-assets/lang/ja.json | 7 + plugins/view-assets/lang/ko.json | 7 + plugins/view-assets/lang/pl.json | 7 + plugins/view-assets/lang/pt-br.json | 7 + plugins/view-assets/lang/pt.json | 7 + plugins/view-assets/lang/ru.json | 7 + plugins/view-assets/lang/tr.json | 7 + plugins/view-assets/lang/zh.json | 7 + .../src/__tests__/tableEdit.test.ts | 444 +++++++++++ .../src/components/list/ListItem.svelte | 1 + .../src/components/list/ListPresenter.svelte | 8 + .../src/components/table/CellGrid.svelte | 735 ++++++++++++++++++ plugins/view-resources/src/index.ts | 3 + plugins/view-resources/src/plugin.ts | 10 +- plugins/view-resources/src/tableEdit/cells.ts | 177 +++++ plugins/view-resources/src/tableEdit/grid.ts | 199 +++++ plugins/view-resources/src/tableEdit/index.ts | 11 + .../view-resources/src/tableEdit/journal.ts | 163 ++++ plugins/view-resources/src/tableEdit/plan.ts | 140 ++++ plugins/view-resources/src/tableEdit/tsv.ts | 76 ++ .../view-resources/src/tableEdit/values.ts | 133 ++++ 47 files changed, 2956 insertions(+), 20 deletions(-) create mode 100644 plugins/tracker-resources/src/bulkEdit/__tests__/issueCells.test.ts create mode 100644 plugins/tracker-resources/src/bulkEdit/issueCells.ts create mode 100644 plugins/view-resources/src/__tests__/tableEdit.test.ts create mode 100644 plugins/view-resources/src/components/table/CellGrid.svelte create mode 100644 plugins/view-resources/src/tableEdit/cells.ts create mode 100644 plugins/view-resources/src/tableEdit/grid.ts create mode 100644 plugins/view-resources/src/tableEdit/index.ts create mode 100644 plugins/view-resources/src/tableEdit/journal.ts create mode 100644 plugins/view-resources/src/tableEdit/plan.ts create mode 100644 plugins/view-resources/src/tableEdit/tsv.ts create mode 100644 plugins/view-resources/src/tableEdit/values.ts diff --git a/models/tracker/src/viewlets.ts b/models/tracker/src/viewlets.ts index 30282c7ae0..bec7656fdf 100644 --- a/models/tracker/src/viewlets.ts +++ b/models/tracker/src/viewlets.ts @@ -55,6 +55,19 @@ const SEARCH_VIEW_OPTIONS: ViewOptionModel[] = [ } ] +// Row height of the table layout (GitHub Projects: "Row height"). Saved with the view like any view option. +const ROW_HEIGHT_VIEW_OPTION: ViewOptionModel = { + key: 'rowHeight', + type: 'dropdown', + defaultValue: 'default', + values: [ + { id: 'compact', label: tracker.string.RowHeightCompact }, + { id: 'default', label: tracker.string.RowHeightDefault }, + { id: 'comfortable', label: tracker.string.RowHeightComfortable } + ], + label: tracker.string.RowHeight +} + export const issuesOptions = (kanban: boolean): ViewOptionsModel => ({ groupBy: [ 'status', @@ -107,7 +120,7 @@ export const issuesOptions = (kanban: boolean): ViewOptionsModel => ({ action: view.function.HideArchived, label: view.string.HideArchived }, - ...(!kanban ? [showColorsViewOption] : []), + ...(!kanban ? [showColorsViewOption, ROW_HEIGHT_VIEW_OPTION] : []), ...SEARCH_VIEW_OPTIONS ] }) diff --git a/plugins/tracker-assets/lang/cs.json b/plugins/tracker-assets/lang/cs.json index 5c6f34bb72..a216e64c07 100644 --- a/plugins/tracker-assets/lang/cs.json +++ b/plugins/tracker-assets/lang/cs.json @@ -1,5 +1,9 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", "CustomFieldFilter": "Field filter", "AddFieldFilter": "Add field filter", "NoFieldFilters": "No custom field filters", diff --git a/plugins/tracker-assets/lang/de.json b/plugins/tracker-assets/lang/de.json index 36919ab268..362aa6dafc 100644 --- a/plugins/tracker-assets/lang/de.json +++ b/plugins/tracker-assets/lang/de.json @@ -1,5 +1,9 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", "CustomFieldFilter": "Field filter", "AddFieldFilter": "Add field filter", "NoFieldFilters": "No custom field filters", diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index 63caeb4260..00ac916dd2 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -1,5 +1,9 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", "CustomFieldFilter": "Field filter", "AddFieldFilter": "Add field filter", "NoFieldFilters": "No custom field filters", diff --git a/plugins/tracker-assets/lang/es.json b/plugins/tracker-assets/lang/es.json index acbe0dfc88..0ed95d591b 100644 --- a/plugins/tracker-assets/lang/es.json +++ b/plugins/tracker-assets/lang/es.json @@ -1,5 +1,9 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", "CustomFieldFilter": "Field filter", "AddFieldFilter": "Add field filter", "NoFieldFilters": "No custom field filters", diff --git a/plugins/tracker-assets/lang/fr.json b/plugins/tracker-assets/lang/fr.json index 0383d21e42..c27995c6c8 100644 --- a/plugins/tracker-assets/lang/fr.json +++ b/plugins/tracker-assets/lang/fr.json @@ -1,5 +1,9 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", "CustomFieldFilter": "Field filter", "AddFieldFilter": "Add field filter", "NoFieldFilters": "No custom field filters", diff --git a/plugins/tracker-assets/lang/it.json b/plugins/tracker-assets/lang/it.json index 56d2525f29..fb957babc7 100644 --- a/plugins/tracker-assets/lang/it.json +++ b/plugins/tracker-assets/lang/it.json @@ -1,5 +1,9 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", "CustomFieldFilter": "Field filter", "AddFieldFilter": "Add field filter", "NoFieldFilters": "No custom field filters", diff --git a/plugins/tracker-assets/lang/ja.json b/plugins/tracker-assets/lang/ja.json index db427333f0..76ee46db17 100644 --- a/plugins/tracker-assets/lang/ja.json +++ b/plugins/tracker-assets/lang/ja.json @@ -1,5 +1,9 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", "CustomFieldFilter": "Field filter", "AddFieldFilter": "Add field filter", "NoFieldFilters": "No custom field filters", diff --git a/plugins/tracker-assets/lang/ko.json b/plugins/tracker-assets/lang/ko.json index 408df5abef..32d1c64216 100644 --- a/plugins/tracker-assets/lang/ko.json +++ b/plugins/tracker-assets/lang/ko.json @@ -1,5 +1,9 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", "CustomFieldFilter": "Field filter", "AddFieldFilter": "Add field filter", "NoFieldFilters": "No custom field filters", diff --git a/plugins/tracker-assets/lang/pl.json b/plugins/tracker-assets/lang/pl.json index b48eaf4d49..9b49b5cc54 100644 --- a/plugins/tracker-assets/lang/pl.json +++ b/plugins/tracker-assets/lang/pl.json @@ -1,5 +1,9 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", "CustomFieldFilter": "Field filter", "AddFieldFilter": "Add field filter", "NoFieldFilters": "No custom field filters", diff --git a/plugins/tracker-assets/lang/pt-br.json b/plugins/tracker-assets/lang/pt-br.json index d1f4a84ca8..10536dfb65 100644 --- a/plugins/tracker-assets/lang/pt-br.json +++ b/plugins/tracker-assets/lang/pt-br.json @@ -1,5 +1,9 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", "CustomFieldFilter": "Field filter", "AddFieldFilter": "Add field filter", "NoFieldFilters": "No custom field filters", diff --git a/plugins/tracker-assets/lang/pt.json b/plugins/tracker-assets/lang/pt.json index 51f6831f41..25af29b23c 100644 --- a/plugins/tracker-assets/lang/pt.json +++ b/plugins/tracker-assets/lang/pt.json @@ -1,5 +1,9 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", "CustomFieldFilter": "Field filter", "AddFieldFilter": "Add field filter", "NoFieldFilters": "No custom field filters", diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index 4506e22727..362efc22f8 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -1,5 +1,9 @@ { "string": { + "RowHeight": "Высота строки", + "RowHeightCompact": "Компактная", + "RowHeightDefault": "Обычная", + "RowHeightComfortable": "Просторная", "CustomFieldFilter": "Фильтр по полям", "AddFieldFilter": "Добавить фильтр по полю", "NoFieldFilters": "Нет фильтров по пользовательским полям", diff --git a/plugins/tracker-assets/lang/tr.json b/plugins/tracker-assets/lang/tr.json index a2b24358ae..a312da0efa 100644 --- a/plugins/tracker-assets/lang/tr.json +++ b/plugins/tracker-assets/lang/tr.json @@ -1,5 +1,9 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", "CustomFieldFilter": "Field filter", "AddFieldFilter": "Add field filter", "NoFieldFilters": "No custom field filters", diff --git a/plugins/tracker-assets/lang/zh.json b/plugins/tracker-assets/lang/zh.json index 9c271b7294..f8dd740a12 100644 --- a/plugins/tracker-assets/lang/zh.json +++ b/plugins/tracker-assets/lang/zh.json @@ -1,5 +1,9 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", "CustomFieldFilter": "Field filter", "AddFieldFilter": "Add field filter", "NoFieldFilters": "No custom field filters", diff --git a/plugins/tracker-resources/src/__mocks__/view-resources.js b/plugins/tracker-resources/src/__mocks__/view-resources.js index 3cba1224f5..76f100827f 100644 --- a/plugins/tracker-resources/src/__mocks__/view-resources.js +++ b/plugins/tracker-resources/src/__mocks__/view-resources.js @@ -5,6 +5,10 @@ const path = require('path') -// Jest stand-in for @hcengineering/view-resources: only the pure filter grammar, without the svelte components. +// Jest stand-in for @hcengineering/view-resources: only the pure modules (filter grammar, table editing), +// without the svelte components. // The path is built at run time so that the type checkers do not pull view-resources sources into this package. -module.exports = { filterGrammar: require(path.resolve(__dirname, '../../../view-resources/src/filter/grammar')) } +module.exports = { + filterGrammar: require(path.resolve(__dirname, '../../../view-resources/src/filter/grammar')), + tableEdit: require(path.resolve(__dirname, '../../../view-resources/src/tableEdit')) +} diff --git a/plugins/tracker-resources/src/bulkEdit/__tests__/issueCells.test.ts b/plugins/tracker-resources/src/bulkEdit/__tests__/issueCells.test.ts new file mode 100644 index 0000000000..b9b3c43a11 --- /dev/null +++ b/plugins/tracker-resources/src/bulkEdit/__tests__/issueCells.test.ts @@ -0,0 +1,272 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Issue, ProjectField } from '@hcengineering/tracker' +import { IssuePriority, ProjectFieldType } from '@hcengineering/tracker' +import { tableEdit } from '@hcengineering/view-resources' + +import { createIssueCellColumns, runIssueOps, type IssueCellLookups, type IssueLabelRef } from '../issueCells' + +const issue = (over: Record & { _id: string }): Issue => + ({ + _class: 'tracker:class:Issue', + space: 'proj', + attachedTo: 'no-parent', + attachedToClass: 'tracker:class:Issue', + collection: 'subIssues', + title: 'Title', + status: 'st-todo', + priority: IssuePriority.NoPriority, + assignee: null, + component: null, + milestone: null, + dueDate: null, + estimation: 0, + ...over + }) as unknown as Issue + +const field = (over: Partial & { key: string, type: ProjectFieldType }): ProjectField => + ({ _id: over.key, label: over.key, position: 0, ...over }) as unknown as ProjectField + +const fields = [ + field({ key: 'note', type: ProjectFieldType.Text }), + field({ key: 'points', type: ProjectFieldType.Number }), + field({ key: 'due', type: ProjectFieldType.Date }), + field({ + key: 'size', + type: ProjectFieldType.SingleSelect, + options: [ + { value: 's', label: 'Small' }, + { value: 'l', label: 'Large' } + ] + }), + field({ + key: 'areas', + type: ProjectFieldType.MultiSelect, + options: [ + { value: 'ui', label: 'UI' }, + { value: 'api', label: 'API' } + ] + }), + field({ key: 'sprint', type: ProjectFieldType.Iteration }) +] + +let refs: Record = {} +let allowed = true + +const lookups: IssueCellLookups = { + statuses: () => [ + { id: 'st-todo', label: 'Todo' }, + { id: 'st-done', label: 'Done' } + ], + priorities: [ + { id: IssuePriority.NoPriority, label: 'No priority' }, + { id: IssuePriority.High, label: 'High' } + ], + assignees: [{ id: 'p1', label: 'Ann' }], + components: [{ id: 'c1', label: 'Core' }], + milestones: [{ id: 'm1', label: 'v1' }], + labels: [ + { id: 'l-bug', title: 'bug', color: 3 }, + { id: 'l-ux', title: 'ux', color: 5 } + ], + labelRefs: (id) => refs[id] ?? [], + fields: new Map(fields.map((f) => [f.key, f])), + canEdit: () => allowed, + now: () => new Date(2026, 0, 10, 12).getTime() +} + +const column = createIssueCellColumns(() => lookups) + +const edit = (key: string, doc: Issue, text: string): tableEdit.ParseResult => { + const col = column(key) + if (col?.edit === undefined) throw new Error(`no editable column ${key}`) + return col.edit(doc, text) +} + +beforeEach(() => { + refs = {} + allowed = true +}) + +describe('issue cell columns', () => { + it('has no column for read-only cells', () => { + expect(column('issue')).toBeUndefined() + expect(column('modified')).toBeUndefined() + expect(column('cf_missing')).toBeUndefined() + // Iteration fields are not edited here + expect(column('cf_sprint')).toBeUndefined() + }) + + it('sets the status by name and refuses to clear it', () => { + const d = issue({ _id: 'i1' }) + expect(column('status')?.format(d)).toBe('Todo') + expect(edit('status', d, 'done')).toMatchObject({ + ok: true, + value: [{ kind: 'update', before: { status: 'st-todo' }, after: { status: 'st-done' } }] + }) + expect(edit('status', d, '')).toEqual({ ok: false, reason: 'notClearable' }) + expect(edit('status', d, 'Blocked')).toEqual({ ok: false, reason: 'unknown' }) + }) + + it('sets and clears the priority', () => { + const d = issue({ _id: 'i1', priority: IssuePriority.High }) + expect(column('priority')?.format(d)).toBe('High') + expect(edit('priority', d, '')).toMatchObject({ ok: true, value: [{ after: { priority: IssuePriority.NoPriority } }] }) + expect(edit('priority', issue({ _id: 'i2' }), 'high')).toMatchObject({ + ok: true, + value: [{ after: { priority: IssuePriority.High } }] + }) + }) + + it('sets and clears the assignee, component and milestone', () => { + const d = issue({ _id: 'i1', assignee: 'p1' as any }) + expect(column('assignee')?.format(d)).toBe('Ann') + expect(edit('assignee', d, '')).toMatchObject({ ok: true, value: [{ before: { assignee: 'p1' }, after: { assignee: null } }] }) + expect(edit('component', issue({ _id: 'i2' }), 'core')).toMatchObject({ ok: true, value: [{ after: { component: 'c1' } }] }) + expect(edit('milestone', issue({ _id: 'i2' }), 'v2')).toEqual({ ok: false, reason: 'unknown' }) + }) + + it('parses due dates and estimation', () => { + const d = issue({ _id: 'i1' }) + expect(edit('dueDate', d, '2026-02-01')).toMatchObject({ + ok: true, + value: [{ after: { dueDate: new Date(2026, 1, 1).getTime() } }] + }) + expect(edit('dueDate', d, '@today+2d')).toMatchObject({ + ok: true, + value: [{ after: { dueDate: new Date(2026, 0, 12).getTime() } }] + }) + expect(edit('dueDate', d, 'soon')).toEqual({ ok: false, reason: 'invalid' }) + expect(edit('estimation', d, '3.5')).toMatchObject({ ok: true, value: [{ after: { estimation: 3.5 } }] }) + expect(edit('estimation', issue({ _id: 'i2', estimation: 4 }), '')).toMatchObject({ + ok: true, + value: [{ before: { estimation: 4 }, after: { estimation: 0 } }] + }) + expect(edit('estimation', d, '-1')).toEqual({ ok: false, reason: 'invalid' }) + expect(column('estimation')?.format(d)).toBe('') + }) + + it('does not clear the title', () => { + const d = issue({ _id: 'i1' }) + expect(edit('title', d, ' New title ')).toMatchObject({ ok: true, value: [{ after: { title: 'New title' } }] }) + expect(edit('title', d, ' ')).toEqual({ ok: false, reason: 'notClearable' }) + }) + + it('reads and writes custom fields of every type', () => { + const d = issue({ _id: 'i1', customFields: { note: 'x', size: 'l', areas: ['ui', 'api'], due: new Date(2026, 5, 1).getTime() } }) + expect(column('cf_note')?.format(d)).toBe('x') + expect(column('cf_size')?.format(d)).toBe('Large') + expect(column('cf_areas')?.format(d)).toBe('UI, API') + expect(column('cf_due')?.format(d)).toBe('2026-06-01') + expect(column('cf_points')?.format(d)).toBe('') + + expect(edit('cf_points', d, '8')).toMatchObject({ + ok: true, + value: [{ after: { customFields: { note: 'x', size: 'l', areas: ['ui', 'api'], due: expect.any(Number), points: 8 } } }] + }) + expect(edit('cf_size', d, 'small')).toMatchObject({ ok: true, value: [{ after: { customFields: expect.objectContaining({ size: 's' }) } }] }) + expect(edit('cf_size', d, 'huge')).toEqual({ ok: false, reason: 'unknown' }) + expect(edit('cf_areas', d, 'api')).toMatchObject({ ok: true, value: [{ after: { customFields: expect.objectContaining({ areas: ['api'] }) } }] }) + expect(edit('cf_points', d, 'many')).toEqual({ ok: false, reason: 'invalid' }) + }) + + it('removes the key when a custom field is cleared and restores an empty record on undo', () => { + const d = issue({ _id: 'i1', customFields: { note: 'x' } }) + const res = edit('cf_note', d, '') + expect(res).toMatchObject({ ok: true, value: [{ before: { customFields: { note: 'x' } }, after: { customFields: {} } }] }) + const bare = issue({ _id: 'i2' }) + const set = edit('cf_note', bare, 'y') + expect(set).toMatchObject({ ok: true, value: [{ before: { customFields: {} }, after: { customFields: { note: 'y' } } }] }) + }) + + it('sets labels by adding and removing references', () => { + refs.i1 = [ + { _id: 'r1', tag: 'l-bug', title: 'bug', color: 3 }, + { _id: 'r2', tag: 'l-ux', title: 'ux', color: 5 } + ] + const d = issue({ _id: 'i1' }) + expect(column('labels')?.format(d)).toBe('bug, ux') + + const res = edit('labels', d, 'ux') + expect(res.ok).toBe(true) + if (res.ok) { + expect(res.value).toHaveLength(1) + expect(res.value[0]).toMatchObject({ + kind: 'remove', + target: { _id: 'r1', attachedTo: 'i1', collection: 'labels' }, + attributes: { tag: 'l-bug', title: 'bug', color: 3 } + }) + } + + const fresh = edit('labels', issue({ _id: 'i2' }), 'bug, ux') + expect(fresh.ok && fresh.value.map((o) => o.kind)).toEqual(['add', 'add']) + expect(fresh.ok && fresh.value[0]).toMatchObject({ attributes: { tag: 'l-bug', title: 'bug', color: 3 }, target: { attachedTo: 'i2' } }) + // Clearing removes every label + expect(edit('labels', d, '')).toMatchObject({ ok: true, value: [{ kind: 'remove' }, { kind: 'remove' }] }) + // An unknown label rejects the cell + expect(edit('labels', d, 'bug, nope')).toEqual({ ok: false, reason: 'unknown' }) + // Same labels: nothing to do + expect(edit('labels', d, 'bug, ux')).toEqual({ ok: true, value: [] }) + }) + + it('is read-only where the user may not change the attribute', () => { + allowed = false + expect(edit('status', issue({ _id: 'i1' }), 'done')).toEqual({ ok: false, reason: 'readonly' }) + expect(edit('cf_note', issue({ _id: 'i1' }), 'x')).toEqual({ ok: false, reason: 'readonly' }) + }) + + it('plans a whole paste: valid cells apply, the others are counted', () => { + const docs = [issue({ _id: 'i1' }), issue({ _id: 'i2' })] + const adapter = { column } + const plan = tableEdit.planEdits( + [ + { doc: docs[0], key: 'status', text: 'Done' }, + { doc: docs[0], key: 'cf_points', text: '5' }, + { doc: docs[1], key: 'status', text: 'Nope' }, + { doc: docs[1], key: 'priority', text: 'High' } + ], + adapter + ) + expect(plan.applied).toBe(3) + expect(plan.skipped).toBe(1) + expect(plan.items).toBe(2) + // The two cells of the first issue are one update + expect(plan.ops.filter((o) => o.target._id === 'i1')).toHaveLength(1) + }) +}) + +describe('runIssueOps', () => { + function fakeClient (result = true): { client: any, calls: any[][] } { + const calls: any[][] = [] + const batch: any = { + updateCollection: async (...args: any[]) => calls.push(['update', ...args]), + addCollection: async (...args: any[]) => calls.push(['add', ...args]), + removeCollection: async (...args: any[]) => calls.push(['remove', ...args]), + commit: async () => ({ result }) + } + return { client: { apply: () => batch }, calls } + } + + const target = { _id: 'i1', _class: 'cls', space: 'sp', attachedTo: 'p', attachedToClass: 'pc', collection: 'subIssues' } + + it('sends every operation in one batch', async () => { + const { client, calls } = fakeClient() + await runIssueOps(client, [ + { kind: 'update', target, before: { a: 1 }, after: { a: 2 } }, + { kind: 'add', target: { ...target, _id: 't1' }, attributes: { tag: 'x' } }, + { kind: 'remove', target: { ...target, _id: 't2' }, attributes: {} } + ]) + expect(calls.map((c) => c[0])).toEqual(['update', 'add', 'remove']) + expect(calls[0].slice(1)).toEqual(['cls', 'sp', 'i1', 'p', 'pc', 'subIssues', { a: 2 }]) + // The id of an added document is kept, so that undo can remove it + expect(calls[1].slice(-1)).toEqual(['t1']) + }) + + it('fails when the batch is rejected', async () => { + const { client } = fakeClient(false) + await expect(runIssueOps(client, [{ kind: 'update', target, before: {}, after: {} }])).rejects.toThrow('rejected') + }) +}) diff --git a/plugins/tracker-resources/src/bulkEdit/issueCells.ts b/plugins/tracker-resources/src/bulkEdit/issueCells.ts new file mode 100644 index 0000000000..63ffae547b --- /dev/null +++ b/plugins/tracker-resources/src/bulkEdit/issueCells.ts @@ -0,0 +1,331 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { generateId, type DocumentUpdate, type TxOperations } from '@hcengineering/core' +import tags from '@hcengineering/tags' +import type { Issue, ProjectField } from '@hcengineering/tracker' +import { IssuePriority, ProjectFieldType, getFieldValue } from '@hcengineering/tracker' +import { tableEdit } from '@hcengineering/view-resources' + +import { mergeCustomFieldValue } from '../projectFields/registry' + +type CellColumn = tableEdit.CellColumn +type EditOp = tableEdit.EditOp +type ValueOption = tableEdit.ValueOption + +/** Label of an issue (a tag reference), as far as bulk editing needs it. */ +export interface IssueLabelRef { + // Id of the reference + _id: string + // Id of the label (tag element) + tag: string + title: string + color: number +} + +/** What the cells of the issue table can be set to. The lists follow the project that is shown. */ +export interface IssueCellLookups { + // Statuses an issue can have (depends on its project and kind) + statuses: (issue: Issue) => Array> + priorities: Array> + assignees: Array> + components: Array> + milestones: Array> + labels: Array<{ id: string, title: string, color: number }> + labelRefs: (issueId: string) => IssueLabelRef[] + // Custom fields by field key + fields: ReadonlyMap + // Whether the current user may change the attribute of the issue + canEdit?: (issue: Issue, attribute: string) => boolean + now?: () => number +} + +const CELL_FIELD_PREFIX = 'cf_' + +/** Key of a custom field column, as marked on the rendered cells. */ +export function customFieldColumnKey (fieldKey: string): string { + return `${CELL_FIELD_PREFIX}${fieldKey}` +} + +export function issueTarget (issue: Issue): tableEdit.DocTarget { + return { + _id: issue._id, + _class: issue._class, + space: issue.space, + attachedTo: issue.attachedTo, + attachedToClass: issue.attachedToClass, + collection: issue.collection + } +} + +const ok = (ops: EditOp[]): tableEdit.ParseResult => ({ ok: true, value: ops }) +const fail = (reason: tableEdit.ParseFailure): tableEdit.ParseResult => ({ ok: false, reason }) + +function setAttribute (issue: Issue, key: string, value: unknown): tableEdit.ParseResult { + return ok([tableEdit.updateOp(issueTarget(issue), issue as unknown as Record, { [key]: value })]) +} + +type Resolve = (lookups: IssueCellLookups) => (issue: Issue) => Array> + +// A column whose value is one of a list of options and may be cleared to null (assignee, component, milestone) +function optionColumn ( + key: string, + attribute: keyof Issue & string, + options: Resolve, + lookups: () => IssueCellLookups +): CellColumn { + return { + key, + clearable: true, + format: (issue) => tableEdit.formatOptionValue(issue[attribute] as string | null, options(lookups())(issue)), + edit: (issue, text) => { + const l = lookups() + if (l.canEdit?.(issue, attribute) === false) return fail('readonly') + const parsed = tableEdit.parseOptionValue(text, options(l)(issue)) + return parsed.ok ? setAttribute(issue, attribute, parsed.value) : parsed + } + } +} + +function numberColumn ( + key: string, + attribute: 'estimation', + lookups: () => IssueCellLookups +): CellColumn { + return { + key, + clearable: true, + // Zero hours is how an issue without an estimate is stored + format: (issue) => (issue[attribute] > 0 ? tableEdit.formatNumberValue(issue[attribute]) : ''), + edit: (issue, text) => { + if (lookups().canEdit?.(issue, attribute) === false) return fail('readonly') + const parsed = tableEdit.parseNumberValue(text, { min: 0 }) + return parsed.ok ? setAttribute(issue, attribute, parsed.value ?? 0) : parsed + } + } +} + +function labelsColumn (lookups: () => IssueCellLookups): CellColumn { + const options = (l: IssueCellLookups): Array> => l.labels.map((it) => ({ id: it.id, label: it.title })) + return { + key: 'labels', + clearable: true, + format: (issue) => + lookups() + .labelRefs(issue._id) + .map((r) => r.title) + .join(', '), + edit: (issue, text) => { + const l = lookups() + if (l.canEdit?.(issue, 'labels') === false) return fail('readonly') + const parsed = tableEdit.parseMultiOptionValue(text, options(l)) + if (!parsed.ok) return parsed + const wanted = new Set(parsed.value) + const current = l.labelRefs(issue._id) + const ops: EditOp[] = [] + // The labels are a collection of references, so the value is set by adding and removing references + for (const ref of current) { + if (!wanted.has(ref.tag)) { + ops.push({ + kind: 'remove', + target: { + _id: ref._id, + _class: tags.class.TagReference, + space: issue.space, + attachedTo: issue._id, + attachedToClass: issue._class, + collection: 'labels' + }, + attributes: { tag: ref.tag, title: ref.title, color: ref.color } + }) + } + } + const have = new Set(current.map((r) => r.tag)) + for (const id of parsed.value) { + if (have.has(id)) continue + const label = l.labels.find((it) => it.id === id) + if (label === undefined) continue + ops.push({ + kind: 'add', + target: { + _id: generateId(), + _class: tags.class.TagReference, + space: issue.space, + attachedTo: issue._id, + attachedToClass: issue._class, + collection: 'labels' + }, + attributes: { tag: label.id, title: label.title, color: label.color } + }) + } + return ok(ops) + } + } +} + +function fieldOptions (field: ProjectField): Array> { + return (field.options ?? []).map((o) => ({ id: o.value, label: o.label })) +} + +function customFieldColumn (field: ProjectField, lookups: () => IssueCellLookups): CellColumn | undefined { + const key = customFieldColumnKey(field.key) + const read = (issue: Issue): ReturnType => getFieldValue(issue.customFields, field) + + let format: (issue: Issue) => string + let parse: (text: string, now: number) => tableEdit.ParseResult + switch (field.type) { + case ProjectFieldType.Text: + format = (issue) => (read(issue) as string | null) ?? '' + parse = (text) => tableEdit.parseTextValue(text) + break + case ProjectFieldType.Number: + format = (issue) => tableEdit.formatNumberValue(read(issue) as number | null) + parse = (text) => tableEdit.parseNumberValue(text) + break + case ProjectFieldType.Date: + format = (issue) => tableEdit.formatDateValue(read(issue) as number | null) + parse = (text, now) => tableEdit.parseDateValue(text, now) + break + case ProjectFieldType.SingleSelect: + format = (issue) => tableEdit.formatOptionValue(read(issue) as string | null, fieldOptions(field)) + parse = (text) => tableEdit.parseOptionValue(text, fieldOptions(field)) + break + case ProjectFieldType.MultiSelect: + format = (issue) => tableEdit.formatMultiOptionValue(read(issue) as string[] | null, fieldOptions(field)) + parse = (text) => tableEdit.parseMultiOptionValue(text, fieldOptions(field)) + break + default: + // Iteration values are managed by the iteration features + return undefined + } + return { + key, + clearable: true, + format, + edit: (issue, text) => { + const l = lookups() + if (l.canEdit?.(issue, 'customFields') === false) return fail('readonly') + const parsed = parse(text, l.now?.() ?? Date.now()) + if (!parsed.ok) return parsed + const customFields = mergeCustomFieldValue(issue.customFields, field.key, parsed.value) + const op = tableEdit.updateOp(issueTarget(issue), issue as unknown as Record, { customFields }) + // An issue without custom fields goes back to an empty record, not to null + if (op.kind === 'update') op.before.customFields = issue.customFields ?? {} + return ok([op]) + } + } +} + +/** + * Cells of the issue table: how each column is shown on the clipboard and how pasted text becomes an update. + * The lookups are read on every call, so the adapter follows the data the view currently has. + */ +export function createIssueCellColumns (lookups: () => IssueCellLookups): (key: string) => CellColumn | undefined { + const columns = new Map() + columns.set('title', { + key: 'title', + format: (issue) => issue.title, + edit: (issue, text) => { + if (lookups().canEdit?.(issue, 'title') === false) return fail('readonly') + const title = text.trim() + // An issue cannot lose its title + return title === '' ? fail('notClearable') : setAttribute(issue, 'title', title) + } + }) + columns.set('status', { + key: 'status', + format: (issue) => tableEdit.formatOptionValue(issue.status, lookups().statuses(issue)), + edit: (issue, text) => { + const l = lookups() + if (l.canEdit?.(issue, 'status') === false) return fail('readonly') + const parsed = tableEdit.parseOptionValue(text, l.statuses(issue)) + if (!parsed.ok) return parsed + // Every issue has a status + return parsed.value === null ? fail('notClearable') : setAttribute(issue, 'status', parsed.value) + } + }) + columns.set('priority', { + key: 'priority', + clearable: true, + format: (issue) => tableEdit.formatOptionValue(issue.priority, lookups().priorities), + edit: (issue, text) => { + const l = lookups() + if (l.canEdit?.(issue, 'priority') === false) return fail('readonly') + const parsed = tableEdit.parseOptionValue(text, l.priorities) + // Clearing the priority sets "No priority" + return parsed.ok ? setAttribute(issue, 'priority', parsed.value ?? IssuePriority.NoPriority) : parsed + } + }) + columns.set('assignee', optionColumn('assignee', 'assignee', (l) => () => l.assignees, lookups)) + columns.set('component', optionColumn('component', 'component', (l) => () => l.components, lookups)) + columns.set('milestone', optionColumn('milestone', 'milestone', (l) => () => l.milestones, lookups)) + columns.set('dueDate', { + key: 'dueDate', + clearable: true, + format: (issue) => tableEdit.formatDateValue(issue.dueDate), + edit: (issue, text) => { + const l = lookups() + if (l.canEdit?.(issue, 'dueDate') === false) return fail('readonly') + const parsed = tableEdit.parseDateValue(text, l.now?.() ?? Date.now()) + return parsed.ok ? setAttribute(issue, 'dueDate', parsed.value) : parsed + } + }) + columns.set('estimation', numberColumn('estimation', 'estimation', lookups)) + columns.set('labels', labelsColumn(lookups)) + + return (key) => { + const known = columns.get(key) + if (known !== undefined) return known + if (!key.startsWith(CELL_FIELD_PREFIX)) return undefined + const field = lookups().fields.get(key.slice(CELL_FIELD_PREFIX.length)) + return field === undefined ? undefined : customFieldColumn(field, lookups) + } +} + +/** + * Applies edit operations to the issues in one atomic batch. + */ +export async function runIssueOps (client: TxOperations, ops: readonly EditOp[]): Promise { + const batch = client.apply() + for (const op of ops) { + const t = op.target + switch (op.kind) { + case 'update': + await batch.updateCollection( + t._class as Issue['_class'], + t.space as Issue['space'], + t._id as Issue['_id'], + t.attachedTo as Issue['attachedTo'], + t.attachedToClass as Issue['attachedToClass'], + t.collection as string, + op.after as DocumentUpdate + ) + break + case 'add': + await batch.addCollection( + t._class as any, + t.space as any, + t.attachedTo as any, + t.attachedToClass as any, + t.collection as string, + op.attributes as any, + t._id as any + ) + break + case 'remove': + await batch.removeCollection( + t._class as any, + t.space as any, + t._id as any, + t.attachedTo as any, + t.attachedToClass as any, + t.collection as string + ) + break + } + } + const res = await batch.commit() + if (!res.result) throw new Error('The changes were rejected: the data was changed meanwhile') +} diff --git a/plugins/tracker-resources/src/components/issues/IssuesView.svelte b/plugins/tracker-resources/src/components/issues/IssuesView.svelte index 3918e69317..4e2c4c2ef7 100644 --- a/plugins/tracker-resources/src/components/issues/IssuesView.svelte +++ b/plugins/tracker-resources/src/components/issues/IssuesView.svelte @@ -1,10 +1,11 @@ + +
+ +
+ +{#if overlay !== undefined} + {#if overlay.range !== undefined} +
+ {/if} +
+ {#if overlay.handle !== undefined} + +
+ {/if} +{/if} +{#if fillPreview !== undefined} +
+{/if} +{#if toast !== undefined} +
+ + {#if toast.kind === 'updated'} + {@const batchId = toast.batchId} + + {/if} +
+{/if} + + diff --git a/plugins/view-resources/src/index.ts b/plugins/view-resources/src/index.ts index 90c464b1a3..8280bdcbbb 100644 --- a/plugins/view-resources/src/index.ts +++ b/plugins/view-resources/src/index.ts @@ -68,6 +68,7 @@ import ReadOnlyNotification from './components/ReadOnlyNotification.svelte' import RolePresenter from './components/RolePresenter.svelte' import SearchSelector from './components/SearchSelector.svelte' import SavedViewBar from './components/view/SavedViewBar.svelte' +import CellGrid from './components/table/CellGrid.svelte' import FilterQueryBar from './components/filter/FilterQueryBar.svelte' import SpaceHeader from './components/SpaceHeader.svelte' import SpacePresenter from './components/SpacePresenter.svelte' @@ -235,6 +236,7 @@ export * from './viewOptions' export * from './clientViewExtension' export * from './savedViews' export * as filterGrammar from './filter/grammar' +export * as tableEdit from './tableEdit' export * from './viewletContextStore' export { getViewletSpecialActions } from './viewletUtils' export { copyMarkdown } from './actionImpl' @@ -268,6 +270,7 @@ export { SortableList, SortableListItem, SavedViewBar, + CellGrid, FilterQueryBar, SpaceHeader, SpacePresenter, diff --git a/plugins/view-resources/src/plugin.ts b/plugins/view-resources/src/plugin.ts index 0ef285ede8..c82157fdd6 100644 --- a/plugins/view-resources/src/plugin.ts +++ b/plugins/view-resources/src/plugin.ts @@ -141,7 +141,15 @@ export default mergeIds(viewId, typedView, { FilterQueryErrorDate: '' as IntlString, FilterQueryErrorNumber: '' as IntlString, FilterQueryErrorRange: '' as IntlString, - FilterQueryErrorKeyword: '' as IntlString + FilterQueryErrorKeyword: '' as IntlString, + + BulkItemsUpdated: '' as IntlString, + BulkItemsUpdatedSkipped: '' as IntlString, + BulkCellsSkipped: '' as IntlString, + BulkUndo: '' as IntlString, + BulkUndone: '' as IntlString, + BulkFailed: '' as IntlString, + BulkUndoFailed: '' as IntlString }, function: { CreateDocMiddleware: '' as Resource, diff --git a/plugins/view-resources/src/tableEdit/cells.ts b/plugins/view-resources/src/tableEdit/cells.ts new file mode 100644 index 0000000000..c60e558747 --- /dev/null +++ b/plugins/view-resources/src/tableEdit/cells.ts @@ -0,0 +1,177 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Finding the cells of the rendered list. A cell has no wrapper element of its own: the presenter is +// bracketed by two comment nodes, `` and ``, and the cell is whatever elements +// sit between them. Rows carry `data-row-doc` with the id of the document. + +const START_PREFIX = 'cell:' +const END_DATA = '/cell' + +export const CELL_END_MARKER = `` +export const ROW_ATTRIBUTE = 'data-row-doc' + +/** Markup of the comment that opens a cell of the column `key`. */ +export function cellStartMarker (key: string): string { + return `` +} + +/** Column key out of the data of an opening comment; undefined for any other comment. */ +export function parseCellMarker (data: string): string | undefined { + if (!data.startsWith(START_PREFIX)) return undefined + try { + return decodeURIComponent(data.slice(START_PREFIX.length)) + } catch { + return undefined + } +} + +export function isCellEnd (data: string): boolean { + return data === END_DATA +} + +export interface RowCells { + // Id of the document of the row + id: string + element: Element + // Elements of each cell by column key + cells: Map +} + +export interface DomGrid { + rows: RowCells[] + // Column keys in the order they appear on screen + cols: string[] +} + +export function isRendered (el: Element): boolean { + return el.getClientRects().length > 0 +} + +/** + * Cells of one row. A row can render a column twice (a compact row repeats some cells in a hidden panel), + * the cell that is actually on screen wins. + */ +export function collectRowCells (row: Element): { keys: string[], cells: Map } { + const keys: string[] = [] + const cells = new Map() + const walker = document.createTreeWalker(row, NodeFilter.SHOW_COMMENT) + for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) { + const key = parseCellMarker((node as Comment).data) + if (key === undefined) continue + const elements: Element[] = [] + for (let sibling = node.nextSibling; sibling !== null; sibling = sibling.nextSibling) { + if (sibling.nodeType === Node.COMMENT_NODE && isCellEnd((sibling as Comment).data)) break + if (sibling.nodeType === Node.ELEMENT_NODE) elements.push(sibling as Element) + } + const known = cells.get(key) + if (known === undefined) { + keys.push(key) + cells.set(key, elements) + } else if (!known.some(isRendered) && elements.some(isRendered)) { + cells.set(key, elements) + } + } + return { keys, cells } +} + +/** + * The visible rows (collapsed groups are skipped) with their cells, in screen order, and the columns. + */ +export function buildGrid (root: ParentNode): DomGrid { + const rows: RowCells[] = [] + const cols: string[] = [] + const seen = new Set() + root.querySelectorAll(`[${ROW_ATTRIBUTE}]`).forEach((element) => { + if (!isRendered(element)) return + const id = element.getAttribute(ROW_ATTRIBUTE) + if (id === null) return + const { keys, cells } = collectRowCells(element) + // Only the cells that are shown make a column + const shown = keys.filter((key) => cells.get(key)?.some(isRendered) === true) + for (const key of shown) { + if (!seen.has(key)) { + seen.add(key) + cols.push(key) + } + } + rows.push({ id, element, cells }) + }) + return { rows, cols } +} + +export interface ScreenRect { + left: number + top: number + right: number + bottom: number +} + +/** Screen box of a cell: the width of its elements and the height of its row. */ +export function cellBox (row: RowCells, key: string): ScreenRect | undefined { + const elements = (row.cells.get(key) ?? []).filter(isRendered) + if (elements.length === 0) return undefined + const rowRect = row.element.getBoundingClientRect() + let left = Infinity + let right = -Infinity + for (const el of elements) { + const r = el.getBoundingClientRect() + left = Math.min(left, r.left) + right = Math.max(right, r.right) + } + return { left, right, top: rowRect.top, bottom: rowRect.bottom } +} + +export function intersect (a: ScreenRect, b: ScreenRect): ScreenRect | undefined { + const res = { + left: Math.max(a.left, b.left), + top: Math.max(a.top, b.top), + right: Math.min(a.right, b.right), + bottom: Math.min(a.bottom, b.bottom) + } + return res.right > res.left && res.bottom > res.top ? res : undefined +} + +/** The part of the screen in which an element can be seen: the intersection of its clipping ancestors. */ +export function visibleArea (el: Element): ScreenRect { + let area: ScreenRect = { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight } + for (let parent = el.parentElement; parent !== null; parent = parent.parentElement) { + const style = getComputedStyle(parent) + if (style.overflowX === 'visible' && style.overflowY === 'visible') continue + const r = parent.getBoundingClientRect() + const next = intersect(area, { left: r.left, top: r.top, right: r.right, bottom: r.bottom }) + if (next === undefined) return { left: 0, top: 0, right: 0, bottom: 0 } + area = next + } + return area +} + +const INTERACTIVE = 'button, a[href], input, textarea, select, [role="button"], .cursor-pointer, .editable, [tabindex]' + +/** Click the part of a cell that opens its editor: the first control, else the cell itself. */ +export function activateCell (row: RowCells, key: string): boolean { + const elements = (row.cells.get(key) ?? []).filter(isRendered) + for (const el of elements) { + const control = el.matches(INTERACTIVE) ? el : el.querySelector(INTERACTIVE) + if (control !== null) { + ;(control as HTMLElement).click() + return true + } + } + const first = elements[0] as HTMLElement | undefined + if (first === undefined) return false + first.click() + return true +} + +/** Plain text of a cell as it is shown. */ +export function cellText (row: RowCells, key: string): string { + return (row.cells.get(key) ?? []) + .filter(isRendered) + .map((el) => (el as HTMLElement).innerText ?? el.textContent ?? '') + .join(' ') + .replace(/\s+/g, ' ') + .trim() +} diff --git a/plugins/view-resources/src/tableEdit/grid.ts b/plugins/view-resources/src/tableEdit/grid.ts new file mode 100644 index 0000000000..67f5b12109 --- /dev/null +++ b/plugins/view-resources/src/tableEdit/grid.ts @@ -0,0 +1,199 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Pure geometry of a cell grid: focus movement, rectangular selection, paste placement and fill-down. +// Rows and columns are zero based indexes into the grid that is currently displayed. + +/** @public */ +export interface CellPos { + row: number + col: number +} + +/** Inclusive rectangle of cells. */ +export interface CellRect { + top: number + left: number + bottom: number + right: number +} + +/** Number of displayed rows and columns. */ +export interface GridDims { + rows: number + cols: number +} + +/** Anchor is where a selection started, focus is the moving end (and the cell that is edited). */ +export interface CellSelection { + anchor: CellPos + focus: CellPos +} + +export type MoveDirection = 'up' | 'down' | 'left' | 'right' + +const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max)) + +export function clampPos (pos: CellPos, dims: GridDims): CellPos { + return { row: clamp(pos.row, 0, Math.max(dims.rows - 1, 0)), col: clamp(pos.col, 0, Math.max(dims.cols - 1, 0)) } +} + +export function samePos (a: CellPos, b: CellPos): boolean { + return a.row === b.row && a.col === b.col +} + +export function rectOf (a: CellPos, b: CellPos): CellRect { + return { + top: Math.min(a.row, b.row), + bottom: Math.max(a.row, b.row), + left: Math.min(a.col, b.col), + right: Math.max(a.col, b.col) + } +} + +export function selectionRect (selection: CellSelection): CellRect { + return rectOf(selection.anchor, selection.focus) +} + +export function rectContains (rect: CellRect, pos: CellPos): boolean { + return pos.row >= rect.top && pos.row <= rect.bottom && pos.col >= rect.left && pos.col <= rect.right +} + +export function rectRows (rect: CellRect): number { + return rect.bottom - rect.top + 1 +} + +export function rectCols (rect: CellRect): number { + return rect.right - rect.left + 1 +} + +export function isSingleCell (rect: CellRect): boolean { + return rectRows(rect) === 1 && rectCols(rect) === 1 +} + +/** All cells of a rectangle, row by row. */ +export function rectCells (rect: CellRect): CellPos[] { + const res: CellPos[] = [] + for (let row = rect.top; row <= rect.bottom; row++) { + for (let col = rect.left; col <= rect.right; col++) res.push({ row, col }) + } + return res +} + +/** + * Move a cell by one step, or to the edge of the grid when `jump` is set. The result stays inside the grid. + */ +export function movePos (pos: CellPos, dir: MoveDirection, dims: GridDims, jump = false): CellPos { + const lastRow = Math.max(dims.rows - 1, 0) + const lastCol = Math.max(dims.cols - 1, 0) + switch (dir) { + case 'up': + return clampPos({ row: jump ? 0 : pos.row - 1, col: pos.col }, dims) + case 'down': + return clampPos({ row: jump ? lastRow : pos.row + 1, col: pos.col }, dims) + case 'left': + return clampPos({ row: pos.row, col: jump ? 0 : pos.col - 1 }, dims) + case 'right': + return clampPos({ row: pos.row, col: jump ? lastCol : pos.col + 1 }, dims) + } +} + +/** Arrow key: the selection collapses to the moved focus cell. */ +export function moveSelection (selection: CellSelection, dir: MoveDirection, dims: GridDims, jump = false): CellSelection { + const focus = movePos(selection.focus, dir, dims, jump) + return { anchor: focus, focus } +} + +/** Shift + arrow key: the anchor stays, the focus end moves, so the selection stays a rectangle. */ +export function extendSelection ( + selection: CellSelection, + dir: MoveDirection, + dims: GridDims, + jump = false +): CellSelection { + return { anchor: selection.anchor, focus: movePos(selection.focus, dir, dims, jump) } +} + +/** + * Tab / Shift+Tab: one cell to the right (left); past the end of a row it continues on the next (previous) row. + * Stays on the first (last) cell at the grid edge. + */ +export function tabPos (pos: CellPos, dims: GridDims, backwards: boolean): CellPos { + if (dims.rows === 0 || dims.cols === 0) return pos + if (!backwards) { + if (pos.col + 1 < dims.cols) return { row: pos.row, col: pos.col + 1 } + return pos.row + 1 < dims.rows ? { row: pos.row + 1, col: 0 } : pos + } + if (pos.col > 0) return { row: pos.row, col: pos.col - 1 } + return pos.row > 0 ? { row: pos.row - 1, col: dims.cols - 1 } : pos +} + +export interface PasteTarget { + target: CellPos + // Index of the clipboard row / column the text comes from + fromRow: number + fromCol: number +} + +export interface PastePlacement { + cells: PasteTarget[] + // Clipboard cells that did not fit into the grid + clipped: number +} + +/** + * Where the cells of a pasted block go. A single value fills every selected cell; a larger block is placed + * from the top left cell of the selection and is cut at the edge of the grid. + */ +export function placePaste (selection: CellRect, block: ReadonlyArray, dims: GridDims): PastePlacement { + const blockRows = block.length + const blockCols = block.reduce((max, row) => Math.max(max, row.length), 0) + if (blockRows === 0 || blockCols === 0) return { cells: [], clipped: 0 } + + if (blockRows === 1 && blockCols === 1) { + return { + cells: rectCells(selection).map((target) => ({ target, fromRow: 0, fromCol: 0 })), + clipped: 0 + } + } + const cells: PasteTarget[] = [] + let clipped = 0 + for (let r = 0; r < blockRows; r++) { + for (let c = 0; c < block[r].length; c++) { + const target = { row: selection.top + r, col: selection.left + c } + if (target.row >= dims.rows || target.col >= dims.cols) clipped++ + else cells.push({ target, fromRow: r, fromCol: c }) + } + } + return { cells, clipped } +} + +export interface FillTarget { + target: CellPos + source: CellPos +} + +/** + * Drag the fill handle of a selection down to `toRow`: the selected rows are repeated downwards + * (a one row selection is copied to every row). Dragging to or above the selection fills nothing. + */ +export function fillDown (selection: CellRect, toRow: number, dims: GridDims): FillTarget[] { + const last = Math.min(toRow, dims.rows - 1) + if (last <= selection.bottom) return [] + const height = rectRows(selection) + const res: FillTarget[] = [] + for (let row = selection.bottom + 1; row <= last; row++) { + const sourceRow = selection.top + ((row - selection.top) % height) + for (let col = selection.left; col <= selection.right; col++) { + res.push({ target: { row, col }, source: { row: sourceRow, col } }) + } + } + return res +} + +/** The selection after a fill: it grows to cover the filled rows. */ +export function extendRectDown (selection: CellRect, toRow: number, dims: GridDims): CellRect { + return { ...selection, bottom: Math.max(selection.bottom, Math.min(toRow, dims.rows - 1)) } +} diff --git a/plugins/view-resources/src/tableEdit/index.ts b/plugins/view-resources/src/tableEdit/index.ts new file mode 100644 index 0000000000..300006c888 --- /dev/null +++ b/plugins/view-resources/src/tableEdit/index.ts @@ -0,0 +1,11 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +export * from './grid' +export * from './journal' +export * from './plan' +export * from './tsv' +export * from './values' +export * from './cells' diff --git a/plugins/view-resources/src/tableEdit/journal.ts b/plugins/view-resources/src/tableEdit/journal.ts new file mode 100644 index 0000000000..1b1cbdf98b --- /dev/null +++ b/plugins/view-resources/src/tableEdit/journal.ts @@ -0,0 +1,163 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Edit operations of a bulk edit and the journal that makes a batch of them undoable. +// Free of platform imports: the operations are plain data, a host turns them into transactions. + +/** @public */ +export interface DocTarget { + _id: string + _class: string + space: string + // Set for documents that are attached to another document + attachedTo?: string + attachedToClass?: string + collection?: string +} + +/** + * One change. `before` and `after` hold the touched attributes, so that every operation can be reverted. + * An attribute that did not have a value is recorded as null. + * @public + */ +export type EditOp = + | { kind: 'update', target: DocTarget, before: Record, after: Record } + | { kind: 'add', target: DocTarget, attributes: Record } + | { kind: 'remove', target: DocTarget, attributes: Record } + +/** @public */ +export interface EditBatch { + id: string + ops: EditOp[] + // Number of distinct documents the batch touches + count: number +} + +const plainEqual = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b) + +/** Update of one document that records the previous values; null for attributes that are not set. */ +export function updateOp (target: DocTarget, doc: Record, patch: Record): EditOp { + const before: Record = {} + for (const key of Object.keys(patch)) before[key] = doc[key] ?? null + return { kind: 'update', target, before, after: { ...patch } } +} + +/** True when applying the operation would not change anything. */ +export function isNoop (op: EditOp): boolean { + if (op.kind !== 'update') return false + return Object.keys(op.after).every((key) => plainEqual(op.before[key] ?? null, op.after[key] ?? null)) +} + +/** + * Merge updates of the same document into one: the first `before` and the last `after` of each attribute win. + * Operations that are not updates keep their order and come after the updates. + */ +export function coalesceOps (ops: readonly EditOp[]): EditOp[] { + const updates = new Map>() + const rest: EditOp[] = [] + for (const op of ops) { + if (op.kind !== 'update') { + rest.push(op) + continue + } + const known = updates.get(op.target._id) + if (known === undefined) { + updates.set(op.target._id, { ...op, before: { ...op.before }, after: { ...op.after } }) + } else { + for (const key of Object.keys(op.after)) { + if (!(key in known.before)) known.before[key] = op.before[key] ?? null + known.after[key] = op.after[key] + } + } + } + return [...[...updates.values()].filter((op) => !isNoop(op)), ...rest] +} + +export function invertOp (op: EditOp): EditOp { + switch (op.kind) { + case 'update': + return { kind: 'update', target: op.target, before: op.after, after: op.before } + case 'add': + return { kind: 'remove', target: op.target, attributes: op.attributes } + case 'remove': + return { kind: 'add', target: op.target, attributes: op.attributes } + } +} + +/** Operations that revert `ops`, last operation first. */ +export function invertOps (ops: readonly EditOp[]): EditOp[] { + return [...ops].reverse().map(invertOp) +} + +export function countTargets (ops: readonly EditOp[]): number { + return new Set(ops.map((op) => op.target._id)).size +} + +export function createBatch (id: string, ops: readonly EditOp[]): EditBatch { + return { id, ops: [...ops], count: countTargets(ops) } +} + +/** + * Applies a list of operations atomically. The host implements it on top of a transaction builder. + * @public + */ +export type OpRunner = (ops: readonly EditOp[]) => Promise + +/** + * Undo and redo stacks of applied batches. A new batch drops the redo stack. + */ +export class EditJournal { + private undoStack: EditBatch[] = [] + private redoStack: EditBatch[] = [] + + constructor (private readonly limit: number = 50) {} + + get canUndo (): boolean { + return this.undoStack.length > 0 + } + + get canRedo (): boolean { + return this.redoStack.length > 0 + } + + /** The batch that undo would revert */ + get lastBatch (): EditBatch | undefined { + return this.undoStack[this.undoStack.length - 1] + } + + record (batch: EditBatch): void { + if (batch.ops.length === 0) return + this.undoStack.push(batch) + if (this.undoStack.length > this.limit) this.undoStack.shift() + this.redoStack = [] + } + + /** + * Revert the last batch (or the given one, when it is still the last). The batch stays undoable + * when reverting fails. + */ + async undo (run: OpRunner, id?: string): Promise { + const batch = this.lastBatch + if (batch === undefined || (id !== undefined && batch.id !== id)) return undefined + await run(invertOps(batch.ops)) + this.undoStack.pop() + this.redoStack.push(batch) + return batch + } + + async redo (run: OpRunner): Promise { + const batch = this.redoStack[this.redoStack.length - 1] + if (batch === undefined) return undefined + await run(batch.ops) + this.redoStack.pop() + this.undoStack.push(batch) + return batch + } + + clear (): void { + this.undoStack = [] + this.redoStack = [] + } +} diff --git a/plugins/view-resources/src/tableEdit/plan.ts b/plugins/view-resources/src/tableEdit/plan.ts new file mode 100644 index 0000000000..1d6e3799f4 --- /dev/null +++ b/plugins/view-resources/src/tableEdit/plan.ts @@ -0,0 +1,140 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { coalesceOps, countTargets, isNoop, type EditOp } from './journal' +import type { CellRect } from './grid' +import type { ParseFailure, ParseResult } from './values' + +/** + * How one column of the table reads and writes its cells. + * @public + */ +export interface CellColumn { + // Key of the column as it is marked on the rendered cells + key: string + // Text of the cell on the clipboard + format: (doc: D) => string + // Operations that set the cell to the value of `text`. Empty text clears the cell. + // Absent for columns that cannot be edited. + edit?: (doc: D, text: string) => ParseResult + // Whether the cell may be cleared with Delete + clearable?: boolean +} + +/** @public */ +export interface CellAdapter { + column: (key: string) => CellColumn | undefined + // Applies the operations in one atomic batch + run: (ops: readonly EditOp[]) => Promise +} + +export interface CellEdit { + doc: D + key: string + text: string +} + +export interface EditPlan { + ops: EditOp[] + // Cells that changed + applied: number + // Cells that already held the value + unchanged: number + // Cells that were left alone because the text did not fit them + skipped: number + skippedBy: Partial> + // Documents touched + items: number +} + +function docId (doc: any): string { + return doc._id as string +} + +/** + * Work out the operations of a set of cell edits without applying them. Edits that cannot be applied + * (wrong type, unknown value, read-only column) are skipped and counted, the others still go through. + * Several cells of one document are combined, each seeing the result of the previous ones. + */ +export function planEdits (edits: ReadonlyArray>, adapter: Pick, 'column'>): EditPlan { + const plan: EditPlan = { ops: [], applied: 0, unchanged: 0, skipped: 0, skippedBy: {}, items: 0 } + const working = new Map() + const all: EditOp[] = [] + + const skip = (reason: ParseFailure): void => { + plan.skipped++ + plan.skippedBy[reason] = (plan.skippedBy[reason] ?? 0) + 1 + } + + for (const edit of edits) { + const column = adapter.column(edit.key) + if (column === undefined) { + skip('noColumn') + continue + } + if (column.edit === undefined) { + skip('readonly') + continue + } + const id = docId(edit.doc) + const current = working.get(id) ?? edit.doc + const res = column.edit(current, edit.text) + if (!res.ok) { + skip(res.reason) + continue + } + if (res.value.every(isNoop) && !res.value.some((op) => op.kind !== 'update')) { + plan.unchanged++ + continue + } + let next = current + for (const op of res.value) { + if (op.kind === 'update') next = { ...next, ...op.after } + all.push(op) + } + working.set(id, next) + plan.applied++ + } + plan.ops = coalesceOps(all) + plan.items = countTargets(plan.ops) + return plan +} + +/** The cells of a rectangle to clear; a column that cannot be cleared is skipped and counted. */ +export function planClear ( + cells: ReadonlyArray<{ doc: D, key: string }>, + adapter: Pick, 'column'> +): EditPlan { + const clearable: Array> = [] + const rejected: EditPlan = { ops: [], applied: 0, unchanged: 0, skipped: 0, skippedBy: {}, items: 0 } + for (const cell of cells) { + const column = adapter.column(cell.key) + if (column?.edit !== undefined && column.clearable !== true) { + rejected.skipped++ + rejected.skippedBy.notClearable = (rejected.skippedBy.notClearable ?? 0) + 1 + continue + } + clearable.push({ ...cell, text: '' }) + } + const plan = planEdits(clearable, adapter) + plan.skipped += rejected.skipped + for (const [reason, count] of Object.entries(rejected.skippedBy)) { + plan.skippedBy[reason as ParseFailure] = (plan.skippedBy[reason as ParseFailure] ?? 0) + (count ?? 0) + } + return plan +} + +/** + * Text of the cells of a rectangle for the clipboard. `read` gives the text of one cell. + */ +export function copyRect (rect: CellRect, read: (row: number, col: number) => string): string[][] { + const rows: string[][] = [] + for (let row = rect.top; row <= rect.bottom; row++) { + const cells: string[] = [] + for (let col = rect.left; col <= rect.right; col++) cells.push(read(row, col)) + rows.push(cells) + } + return rows +} diff --git a/plugins/view-resources/src/tableEdit/tsv.ts b/plugins/view-resources/src/tableEdit/tsv.ts new file mode 100644 index 0000000000..d5cc648a2e --- /dev/null +++ b/plugins/view-resources/src/tableEdit/tsv.ts @@ -0,0 +1,76 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Tab separated clipboard text, as produced and understood by spreadsheets: cells are separated by a tab, +// rows by a line break, and a cell holding a tab, a line break or a quote is wrapped in quotes with inner quotes doubled. + +function encodeCell (cell: string): string { + return /[\t\r\n"]/.test(cell) ? `"${cell.replace(/"/g, '""')}"` : cell +} + +/** + * Encode a block of cells to clipboard text. + * @public + */ +export function encodeTsv (rows: ReadonlyArray): string { + return rows.map((row) => row.map(encodeCell).join('\t')).join('\n') +} + +/** + * Decode clipboard text to a block of cells. A single trailing line break (added by spreadsheets + * when copying) does not make an extra row. Empty text gives no rows. + * @public + */ +export function decodeTsv (text: string): string[][] { + if (text === '') return [] + const rows: string[][] = [] + let row: string[] = [] + let cell = '' + let quoted = false + let cellStart = true + + const endCell = (): void => { + row.push(cell) + cell = '' + cellStart = true + } + const endRow = (): void => { + endCell() + rows.push(row) + row = [] + } + + for (let i = 0; i < text.length; i++) { + const ch = text[i] + if (quoted) { + if (ch === '"') { + if (text[i + 1] === '"') { + cell += '"' + i++ + } else { + quoted = false + } + } else { + cell += ch + } + continue + } + if (ch === '"' && cellStart) { + quoted = true + cellStart = false + } else if (ch === '\t') { + endCell() + } else if (ch === '\n' || ch === '\r') { + if (ch === '\r' && text[i + 1] === '\n') i++ + endRow() + } else { + cell += ch + cellStart = false + } + } + // The last row has no line break after it, unless the text ended with one + if (cell !== '' || row.length > 0 || quoted) endRow() + return rows +} diff --git a/plugins/view-resources/src/tableEdit/values.ts b/plugins/view-resources/src/tableEdit/values.ts new file mode 100644 index 0000000000..30e05653b3 --- /dev/null +++ b/plugins/view-resources/src/tableEdit/values.ts @@ -0,0 +1,133 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { parseDateScalar, resolveDate } from '../filter/grammar/values' + +// Per-type parsing and formatting of cell text, used by paste, fill and clear. +// Empty text means "clear the value": every parser answers null (or an empty list) for it. + +/** @public */ +export type ParseFailure = 'invalid' | 'unknown' | 'ambiguous' | 'readonly' | 'notClearable' | 'noColumn' + +/** @public */ +export type ParseResult = { ok: true, value: T } | { ok: false, reason: ParseFailure } + +/** @public */ +export interface ValueOption { + id: Id + label: string +} + +const ok = (value: T): ParseResult => ({ ok: true, value }) +const fail = (reason: ParseFailure): ParseResult => ({ ok: false, reason }) + +export function parseTextValue (text: string): ParseResult { + const value = text.trim() + return ok(value === '' ? null : value) +} + +export interface NumberParseOptions { + integer?: boolean + min?: number +} + +export function parseNumberValue (text: string, options: NumberParseOptions = {}): ParseResult { + const raw = text.trim() + if (raw === '') return ok(null) + if (!/^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i.test(raw)) return fail('invalid') + const value = Number(raw) + if (!Number.isFinite(value)) return fail('invalid') + if (options.integer === true && !Number.isInteger(value)) return fail('invalid') + if (options.min !== undefined && value < options.min) return fail('invalid') + return ok(value) +} + +const ISO_PREFIX = /^(\d{4}-\d{2}-\d{2})(?:[T ].*)?$/ +const SLASH_DATE = /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/ + +/** + * A date as the start of its local day. Understands `YYYY-MM-DD` (also followed by a time), `YYYY/M/D`, + * `@today` and `@today+3d`, the syntaxes of the filter string. + */ +export function parseDateValue (text: string, now: number = Date.now()): ParseResult { + let raw = text.trim() + if (raw === '') return ok(null) + const iso = ISO_PREFIX.exec(raw) + if (iso !== null) raw = iso[1] + const slash = SLASH_DATE.exec(raw) + if (slash !== null) raw = `${slash[1]}-${slash[2].padStart(2, '0')}-${slash[3].padStart(2, '0')}` + const scalar = parseDateScalar(raw.toLowerCase()) + if (scalar === undefined || scalar.kind !== 'date') return fail('invalid') + return ok(resolveDate(scalar, now)) +} + +/** `YYYY-MM-DD` of the local day, or empty text for no date. */ +export function formatDateValue (value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) return '' + const d = new Date(value) + const pad = (n: number): string => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +export function formatNumberValue (value: number | null | undefined): string { + return value === null || value === undefined || !Number.isFinite(value) ? '' : String(value) +} + +const norm = (s: string): string => s.trim().toLowerCase() + +/** + * Pick one option by its label (case insensitive), or by its id. The label wins over an id of another option. + */ +export function parseOptionValue ( + text: string, + options: ReadonlyArray> +): ParseResult { + const raw = norm(text) + if (raw === '') return ok(null) + const byLabel = options.filter((o) => norm(o.label) === raw) + if (byLabel.length === 1) return ok(byLabel[0].id) + if (byLabel.length > 1) return fail('ambiguous') + const byId = options.filter((o) => norm(String(o.id)) === raw) + if (byId.length === 1) return ok(byId[0].id) + return fail('unknown') +} + +/** + * Several options separated by a comma or a semicolon. One unknown name rejects the whole cell, + * so that a paste never silently drops part of a value. + */ +export function parseMultiOptionValue ( + text: string, + options: ReadonlyArray> +): ParseResult { + if (text.trim() === '') return ok([]) + const res: Id[] = [] + for (const part of text.split(/[,;]/)) { + if (part.trim() === '') continue + const one = parseOptionValue(part, options) + if (!one.ok) return fail(one.reason) + if (one.value !== null && !res.includes(one.value)) res.push(one.value) + } + return ok(res) +} + +export function formatOptionValue ( + id: Id | null | undefined, + options: ReadonlyArray> +): string { + if (id === null || id === undefined) return '' + return options.find((o) => o.id === id)?.label ?? '' +} + +export function formatMultiOptionValue ( + ids: readonly Id[] | null | undefined, + options: ReadonlyArray> +): string { + if (ids === null || ids === undefined) return '' + return ids + .map((id) => formatOptionValue(id, options)) + .filter((s) => s !== '') + .join(', ') +} From e73fd25e291af4919a2c5bd82a19743441741cd5 Mon Sep 17 00:00:00 2001 From: Shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:48:35 +0530 Subject: [PATCH 20/32] feat(tracker): iteration fields with settings, filters, grouping and cleanup --- docs/agentlog.md | 61 ++ docs/tracker-projects-parity/plan.md | 49 +- models/server-tracker/src/index.ts | 8 + models/tracker/src/index.ts | 2 + models/tracker/src/types.ts | 32 ++ plugins/tracker-assets/lang/cs.json | 29 +- plugins/tracker-assets/lang/de.json | 29 +- plugins/tracker-assets/lang/en.json | 29 +- plugins/tracker-assets/lang/es.json | 29 +- plugins/tracker-assets/lang/fr.json | 29 +- plugins/tracker-assets/lang/it.json | 29 +- plugins/tracker-assets/lang/ja.json | 29 +- plugins/tracker-assets/lang/ko.json | 29 +- plugins/tracker-assets/lang/pl.json | 29 +- plugins/tracker-assets/lang/pt-br.json | 29 +- plugins/tracker-assets/lang/pt.json | 29 +- plugins/tracker-assets/lang/ru.json | 29 +- plugins/tracker-assets/lang/tr.json | 29 +- plugins/tracker-assets/lang/zh.json | 29 +- .../src/__tests__/issueFilter.test.ts | 86 ++- .../src/bulkEdit/__tests__/issueCells.test.ts | 42 +- .../src/bulkEdit/issueCells.ts | 42 +- .../src/components/issues/IssuesView.svelte | 27 +- plugins/tracker-resources/src/issueFilter.ts | 24 +- .../iterations/IterationFieldEditor.svelte | 71 +++ .../iterations/IterationGroupExtras.svelte | 86 +++ .../iterations/IterationGroupHeader.svelte | 27 + .../src/iterations/IterationPresenter.svelte | 28 + .../iterations/IterationPresenter.svelte.x | 28 + .../src/iterations/IterationRow.svelte | 158 ++++++ .../src/iterations/IterationsEditor.svelte | 130 +++++ .../src/iterations/actions.ts | 93 +++ .../src/iterations/iterationsStore.ts | 61 ++ plugins/tracker-resources/src/plugin.ts | 27 + .../projectFields/CustomFieldColumn.svelte | 2 +- .../CustomFieldFilterPopup.svelte | 40 +- .../projectFields/CustomFieldPresenter.svelte | 11 + .../src/projectFields/FieldValueEditor.svelte | 3 + .../projectFields/IssueCustomFields.svelte | 4 +- .../projectFields/ProjectFieldsPopup.svelte | 139 ++++- .../src/projectFields/__tests__/query.test.ts | 80 ++- .../src/projectFields/customFieldView.ts | 62 +- .../src/projectFields/query.ts | 88 ++- .../src/__tests__/iteration-rollup.test.ts | 47 ++ .../tracker/src/__tests__/iteration.test.ts | 392 +++++++++++++ plugins/tracker/src/index.ts | 4 + plugins/tracker/src/iteration.ts | 529 ++++++++++++++++++ plugins/tracker/src/iterationRollup.ts | 65 +++ .../view-resources/src/clientViewExtension.ts | 5 + .../src/components/list/List.svelte | 6 +- .../src/components/list/ListHeader.svelte | 12 + .../__tests__/project-field-trigger.test.ts | 94 +++- server-plugins/tracker-resources/src/index.ts | 54 +- server-plugins/tracker/src/index.ts | 1 + 54 files changed, 3053 insertions(+), 73 deletions(-) create mode 100644 docs/agentlog.md create mode 100644 plugins/tracker-resources/src/iterations/IterationFieldEditor.svelte create mode 100644 plugins/tracker-resources/src/iterations/IterationGroupExtras.svelte create mode 100644 plugins/tracker-resources/src/iterations/IterationGroupHeader.svelte create mode 100644 plugins/tracker-resources/src/iterations/IterationPresenter.svelte create mode 100644 plugins/tracker-resources/src/iterations/IterationPresenter.svelte.x create mode 100644 plugins/tracker-resources/src/iterations/IterationRow.svelte create mode 100644 plugins/tracker-resources/src/iterations/IterationsEditor.svelte create mode 100644 plugins/tracker-resources/src/iterations/actions.ts create mode 100644 plugins/tracker-resources/src/iterations/iterationsStore.ts create mode 100644 plugins/tracker/src/__tests__/iteration-rollup.test.ts create mode 100644 plugins/tracker/src/__tests__/iteration.test.ts create mode 100644 plugins/tracker/src/iteration.ts create mode 100644 plugins/tracker/src/iterationRollup.ts diff --git a/docs/agentlog.md b/docs/agentlog.md new file mode 100644 index 0000000000..7e1f1914be --- /dev/null +++ b/docs/agentlog.md @@ -0,0 +1,61 @@ +# Agent log + +Append-only. Newest entries at the bottom. Read only the tail if you are continuing work. + +## 2026-10-03 — Tracker → GitHub Projects parity: discovery + plan + +- Created branch `feat/tracker-projects-parity` off `d1a668cb2`. +- Mapped the tracker feature. Key correction: `plugins/tracker` is declarations only; + all UI lives in `plugins/tracker-resources`, model in `models/tracker`, + generic view infra in `plugins/view` + `plugins/view-resources`. +- Verified the biggest gap: Huly model is **TxModel-fixed**, so user-defined custom + fields cannot be real `core.class.Attribute`s. The `custom*` convention in + `ViewletSetting.svelte:230` is about derived/exported column *labels*, not runtime + attributes. Plan therefore uses a `ProjectField` definition doc + a + `Record` shadow prop on `Issue`. +- Verified `ViewOptions.groupBy` is already `string[]` and `groupDepth` exists in + `ViewOptionsModel` but is hardcoded to 1 — nested grouping needs only a real builder. +- Verified `view.class.FilteredView` is already a server-persisted saved view with + `filters` + `viewOptions` + `sharable` + `users`, but is scoped to the workspace + alias, not a project, and cannot carry a column set. +- Plan written to `docs/tracker-projects-parity/plan.md` (8 phases, 7 architecture + decisions, risk table). + +## 2026-10-03 — Reconciled GitHub Projects research into the plan (plan REWRITTEN) + +Research agent came back with verified corrections (official docs + live GraphQL schema ++ REST + `gh` CLI v2.98.0). Rewrote `plan.md` from scratch. Scope got **smaller and +sharper**, not bigger: + +- GitHub has exactly **3 layouts** (Table, Board, Roadmap). No calendar, no gantt-timeline, + no workload. Insights is a separate top-level section, not a layout. +- The view names in the screenshot (Current iteration, Next iteration, Prioritized + backlog, In review, My items) are **NOT GitHub defaults** — GitHub ships none. They are + community/template convention. We ship them as a seeded template. +- **No custom trigger→action automation builder** in GitHub. Only 2 built-in workflows + + auto-add + auto-archive. Recommending a field-change webhook over a rule engine. +- Exactly **6 custom field types**: TEXT, NUMBER, DATE, SINGLE_SELECT, MULTI_SELECT, + ITERATION. No checkbox, no people, no URL. Max 50 fields/project, 50 options/select. +- No "stack by" / nested grouping. No "Health"/"Priority"/"Estimate"/"Due date" built-ins. + No pie charts (6 layouts: Bar/Column/Stacked bar/Stacked column/Stacked area/Line). +- Export is `.tsv`, not CSV. +- Views are **project-wide and shared** — GitHub has **no personal views**. Dropped the + personal/shared split from the design. + +**New highest-leverage decision added — D3: adopt GitHub's filter grammar** (field:/has:/no:/ +is:/@me/@today/@current/@next/@previous/ranges a..b/wildcards/AND/OR+parens) as a pure +tokenizer+parser+compiler module, with TWO consumers: server-side string→DocumentQuery, and +client-side evaluation for custom fields the server cannot index. Sharing the tokenizer means +they cannot drift. + +**Honest scope cut recorded — D6:** historical Insights charts (X-axis=Time, Open/Completed +series, "Burn up" default) need field-value history that Huly does not have. Options are +replay `DOMAIN_TX` (degrades), snapshot collection (correct, costly), or ship current +charts only. Recommendation: current charts only in v1. Do NOT fake burn-up with TX replay. + +Phase list went from 8 to 12 and is now ordered by real dependency: fields(0-1) → views(2) +→ grammar(3) → bulk edit(4) → iterations(5) → roadmap(6) → board(7) → hierarchy/slice/sum(8) +→ insights(9) → workflows(10) → project-level roles(11). + +Next: get answers on the 7 open questions in plan.md §8, then time-box Phase 0 as a +2-3 day spike to validate the custom-field-on-record design end to end. \ No newline at end of file diff --git a/docs/tracker-projects-parity/plan.md b/docs/tracker-projects-parity/plan.md index ec336efa97..54a77f12b5 100644 --- a/docs/tracker-projects-parity/plan.md +++ b/docs/tracker-projects-parity/plan.md @@ -535,4 +535,51 @@ rush build --to # cross-package type check before PR --- -*Append-only. Next entry should record the Phase 0 spike result.* \ No newline at end of file +*Append-only. Next entry should record the Phase 0 spike result.* + +--- + +## Phase 5 implementation notes (Iterations) + +**Deviations from D4** + +- **No `TIssue.iteration` prop.** An issue's iteration is the iteration id stored in + `issue.customFields[field.key]`, like every other custom field (D1). A project can therefore have several + Iteration fields, each with its own iterations, and no model migration or index is needed. The cost is the + same as for all custom fields: filter/sort/group run on the client over the scanned issues. +- **`tracker.class.Iteration` has no `project` prop**: `space` is the project (D4 says the same). `field` + references the owning `ProjectField`. `number` is the ordinal for default titles (`Iteration 4`), 0 for breaks. +- **No stored status.** `getIterationState(iteration, now)` derives `planned | current | completed` from + `startDate` + `duration` (whole local days; `end` is the last millisecond of the last day). +- Default duration of a new iteration is not stored on the field: it is the duration of the last iteration + (a week when there is none). The creation form asks for the duration (days or weeks) and the first start date + (default today) and creates three consecutive iterations together with the field in one `client.apply` batch + (`generateInitialIterations`). + +**Behaviour** + +- Changing the duration or start date of an iteration moves all later iterations (and breaks) by the same + number of days, so gaps stay; a start that would run into the previous iteration is rejected (`planIterationChange`). + Adding appends after the last item; "Insert break after" inserts a break (`isBreak`, not assignable, not + offered in editors/groups/filters, ignored by `@current/@next/@previous`) and shifts what follows. + Deleting leaves the other dates alone; the server (`OnIterationRemove`) strips the id from issues. Removing + the field or the project removes its iterations. Completed iterations are listed in their own collapsible section. +- Editor: single-select popup, current iteration marked, `No iteration`; presenter shows the title with the date + range as tooltip. Cell paste accepts titles and `@current/@next/@previous[+-N]`. +- Filter: `iteration:@current`, `@next`, `@previous`, `+/-N`, `>@current`, titles (grammar callback is wired to the + project's iterations); the legacy custom-field filter has an any-of rule with the three keywords plus iterations. +- Sort by iteration start date; group by iteration (calendar order, `No ` last). The group header shows the + date range, count, done count (status category Won) and estimation sum (`computeIterationRollups`), plus a menu + `Move items to...`. +- View extension additions (`ClientViewExtension`): optional `extraProjection` and `getGroupExtras`. + +**Remaining gaps / not done** + +- "Move items to..." moves the items of the group as currently shown (respects the active filter), not every + item of the iteration in the project. +- Board column field and Roadmap date source from iterations are Phase 7 / 12 (the data and pure helpers exist). +- Iteration settings are saved per edit, not with the form's Save button; no undo. +- Not exercised in a running UI or e2e (no sanity spec added); verified by jest, `rush validate`, `svelte-check`. +- `rush validate --from @hcengineering/tracker` fails only in `@hcengineering/prod` on type errors in earlier + phase files (`view-resources` filter grammar parser/compile and `tableEdit`), unrelated to this phase. + diff --git a/models/server-tracker/src/index.ts b/models/server-tracker/src/index.ts index 8404e7a848..1303b0a714 100644 --- a/models/server-tracker/src/index.ts +++ b/models/server-tracker/src/index.ts @@ -89,6 +89,14 @@ export function createModel (builder: Builder): void { } }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTracker.trigger.OnIterationRemove, + txMatch: { + _class: core.class.TxRemoveDoc, + objectClass: tracker.class.Iteration + } + }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverTracker.trigger.OnDependencyShiftRequest, txMatch: { diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index 8e0e6ca53f..136ef129d8 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -48,6 +48,7 @@ import { TMilestone, TProject, TProjectField, + TIteration, TProjectTargetPreference, TRelatedIssueTarget, TTimeSpendReport, @@ -481,6 +482,7 @@ export function createModel (builder: Builder): void { TTypeIssuePriority, TMilestone, TProjectField, + TIteration, TTypeMilestoneStatus, TTimeSpendReport, TTypeReportedTime, diff --git a/models/tracker/src/types.ts b/models/tracker/src/types.ts index 63566ddfa7..85b1a256c9 100644 --- a/models/tracker/src/types.ts +++ b/models/tracker/src/types.ts @@ -40,6 +40,7 @@ import { Model, Prop, ReadOnly, + TypeBoolean, TypeCollaborativeDoc, TypeDate, TypeMarkup, @@ -71,6 +72,7 @@ import { type IssueTemplate, type IssueTemplateChild, type Milestone, + type Iteration, type ProjectField, type ProjectFieldOption, ProjectFieldType, @@ -502,6 +504,36 @@ export class TProjectField extends TDoc implements ProjectField { declare space: Ref } +/** + * A time box of an Iteration field. Issues reference it by id from `customFields[field.key]`. + * @public + */ +@Model(tracker.class.Iteration, core.class.Doc, DOMAIN_TRACKER) +@UX(tracker.string.Iteration, tracker.icon.Issues, '', 'label', undefined, tracker.string.Iterations) +export class TIteration extends TDoc implements Iteration { + @Prop(TypeRef(tracker.class.ProjectField), tracker.string.ProjectField) + @ReadOnly() + field!: Ref + + @Prop(TypeString(), tracker.string.Title) + label!: string + + @Prop(TypeNumber(), tracker.string.Number) + @Hidden() + number!: number + + @Prop(TypeDate(), tracker.string.StartDate) + startDate!: Timestamp + + @Prop(TypeNumber(), tracker.string.IterationDuration) + duration!: number + + @Prop(TypeBoolean(), tracker.string.IterationBreak) + isBreak?: boolean + + declare space: Ref +} + @UX(core.string.Number) @Model(tracker.class.TypeReportedTime, core.class.Type) export class TTypeReportedTime extends TType {} diff --git a/plugins/tracker-assets/lang/cs.json b/plugins/tracker-assets/lang/cs.json index a216e64c07..aee37cebe7 100644 --- a/plugins/tracker-assets/lang/cs.json +++ b/plugins/tracker-assets/lang/cs.json @@ -372,7 +372,34 @@ "SearchEmptyTitle": "Pro „{query}\" nebyly nalezeny žádné úkoly", "SearchEmptyActiveFilters": "Aktivní filtry: {filters}", "SearchEmptyClearFilters": "Hledat bez filtrů", - "SearchEmptyAllProjects": "Hledat ve všech projektech" + "SearchEmptyAllProjects": "Hledat ve všech projektech", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration" }, "status": {} } diff --git a/plugins/tracker-assets/lang/de.json b/plugins/tracker-assets/lang/de.json index 362aa6dafc..8e263a1b80 100644 --- a/plugins/tracker-assets/lang/de.json +++ b/plugins/tracker-assets/lang/de.json @@ -620,7 +620,34 @@ "SearchEmptyTitle": "Keine Treffer für \"{query}\"", "SearchEmptyActiveFilters": "Aktive Filter: {filters}", "SearchEmptyClearFilters": "Suche ohne Filter", - "SearchEmptyAllProjects": "In allen Projekten suchen" + "SearchEmptyAllProjects": "In allen Projekten suchen", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration" }, "status": {} } diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index 00ac916dd2..09391a76ab 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -620,7 +620,34 @@ "SearchEmptyTitle": "No issues found for \"{query}\"", "SearchEmptyActiveFilters": "Active filters: {filters}", "SearchEmptyClearFilters": "Search without filters", - "SearchEmptyAllProjects": "Search in all projects" + "SearchEmptyAllProjects": "Search in all projects", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration" }, "status": {} } diff --git a/plugins/tracker-assets/lang/es.json b/plugins/tracker-assets/lang/es.json index 0ed95d591b..7921b05f82 100644 --- a/plugins/tracker-assets/lang/es.json +++ b/plugins/tracker-assets/lang/es.json @@ -365,7 +365,34 @@ "SearchEmptyTitle": "No se encontraron tareas para \"{query}\"", "SearchEmptyActiveFilters": "Filtros activos: {filters}", "SearchEmptyClearFilters": "Buscar sin filtros", - "SearchEmptyAllProjects": "Buscar en todos los proyectos" + "SearchEmptyAllProjects": "Buscar en todos los proyectos", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration" }, "status": {} } diff --git a/plugins/tracker-assets/lang/fr.json b/plugins/tracker-assets/lang/fr.json index c27995c6c8..54acd2e9e9 100644 --- a/plugins/tracker-assets/lang/fr.json +++ b/plugins/tracker-assets/lang/fr.json @@ -365,7 +365,34 @@ "SearchEmptyTitle": "Aucun problème trouvé pour \"{query}\"", "SearchEmptyActiveFilters": "Filtres actifs : {filters}", "SearchEmptyClearFilters": "Rechercher sans filtres", - "SearchEmptyAllProjects": "Rechercher dans tous les projets" + "SearchEmptyAllProjects": "Rechercher dans tous les projets", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration" }, "status": {} } diff --git a/plugins/tracker-assets/lang/it.json b/plugins/tracker-assets/lang/it.json index fb957babc7..1eba8f3765 100644 --- a/plugins/tracker-assets/lang/it.json +++ b/plugins/tracker-assets/lang/it.json @@ -365,7 +365,34 @@ "SearchEmptyTitle": "Nessuna issue trovata per \"{query}\"", "SearchEmptyActiveFilters": "Filtri attivi: {filters}", "SearchEmptyClearFilters": "Cerca senza filtri", - "SearchEmptyAllProjects": "Cerca in tutti i progetti" + "SearchEmptyAllProjects": "Cerca in tutti i progetti", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration" }, "status": {} } diff --git a/plugins/tracker-assets/lang/ja.json b/plugins/tracker-assets/lang/ja.json index 76ee46db17..092fa17f4f 100644 --- a/plugins/tracker-assets/lang/ja.json +++ b/plugins/tracker-assets/lang/ja.json @@ -365,7 +365,34 @@ "SearchEmptyTitle": "\"{query}\" に一致するイシューが見つかりません", "SearchEmptyActiveFilters": "アクティブなフィルター:{filters}", "SearchEmptyClearFilters": "フィルターなしで検索", - "SearchEmptyAllProjects": "すべてのプロジェクトを検索" + "SearchEmptyAllProjects": "すべてのプロジェクトを検索", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration" }, "status": {} } diff --git a/plugins/tracker-assets/lang/ko.json b/plugins/tracker-assets/lang/ko.json index 32d1c64216..9d6d9efa39 100644 --- a/plugins/tracker-assets/lang/ko.json +++ b/plugins/tracker-assets/lang/ko.json @@ -365,7 +365,34 @@ "SearchEmptyTitle": "\"{query}\"에 대한 이슈를 찾을 수 없습니다", "SearchEmptyActiveFilters": "활성 필터: {filters}", "SearchEmptyClearFilters": "필터 없이 검색", - "SearchEmptyAllProjects": "모든 프로젝트에서 검색" + "SearchEmptyAllProjects": "모든 프로젝트에서 검색", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration" }, "status": {} } diff --git a/plugins/tracker-assets/lang/pl.json b/plugins/tracker-assets/lang/pl.json index 9b49b5cc54..d2cbc038f8 100644 --- a/plugins/tracker-assets/lang/pl.json +++ b/plugins/tracker-assets/lang/pl.json @@ -349,7 +349,34 @@ "UnsetParentIssue": "Wyczyść zagadnienie nadrzędne", "ForbidCreateProjectPermission": "Zakaż tworzenia projektów", "ForbidCreateProjectPermissionDescription": "Zakaż użytkownikom tworzenia nowych projektów.", - "AllowCreatingIssues": "Zezwól na tworzenie zadań" + "AllowCreatingIssues": "Zezwól na tworzenie zadań", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration" }, "status": {} } diff --git a/plugins/tracker-assets/lang/pt-br.json b/plugins/tracker-assets/lang/pt-br.json index 10536dfb65..3c81b9c701 100644 --- a/plugins/tracker-assets/lang/pt-br.json +++ b/plugins/tracker-assets/lang/pt-br.json @@ -365,7 +365,34 @@ "SearchEmptyTitle": "Nenhum problema encontrado para \"{query}\"", "SearchEmptyActiveFilters": "Filtros ativos: {filters}", "SearchEmptyClearFilters": "Procurar sem filtros", - "SearchEmptyAllProjects": "Procurar em todos os projetos" + "SearchEmptyAllProjects": "Procurar em todos os projetos", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration" }, "status": {} } diff --git a/plugins/tracker-assets/lang/pt.json b/plugins/tracker-assets/lang/pt.json index 25af29b23c..4fc0c88afa 100644 --- a/plugins/tracker-assets/lang/pt.json +++ b/plugins/tracker-assets/lang/pt.json @@ -365,7 +365,34 @@ "SearchEmptyTitle": "Nenhum problema encontrado para \"{query}\"", "SearchEmptyActiveFilters": "Filtros ativos: {filters}", "SearchEmptyClearFilters": "Procurar sem filtros", - "SearchEmptyAllProjects": "Procurar em todos os projetos" + "SearchEmptyAllProjects": "Procurar em todos os projetos", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration" }, "status": {} } diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index 362efc22f8..725f9e3bfc 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -620,7 +620,34 @@ "SearchEmptyTitle": "Не найдено задач по запросу «{query}»", "SearchEmptyActiveFilters": "Активные фильтры: {filters}", "SearchEmptyClearFilters": "Искать без фильтров", - "SearchEmptyAllProjects": "Искать во всех проектах" + "SearchEmptyAllProjects": "Искать во всех проектах", + "Iteration": "Итерация", + "Iterations": "Итерации", + "IterationDuration": "Длительность", + "IterationBreak": "Перерыв", + "IterationTitlePlaceholder": "Название итерации", + "IterationStartsOn": "Начало", + "IterationDurationDays": "дн.", + "IterationDurationWeeks": "нед.", + "IterationCurrentMarker": "текущая", + "IterationCurrent": "Текущая итерация", + "IterationNext": "Следующая итерация", + "IterationPrevious": "Предыдущая итерация", + "NoIteration": "Без итерации", + "NoIterations": "Итераций пока нет", + "AddIteration": "Добавить итерацию", + "InsertIterationBreak": "Вставить перерыв после", + "DeleteIteration": "Удалить итерацию", + "DeleteIterationConfirm": "У задач из «{name}» значение итерации будет сброшено.", + "UpcomingIterations": "Текущая и будущие итерации", + "CompletedIterations": "Завершённые итерации", + "IterationSavedImmediately": "Изменения итераций сохраняются сразу. Изменение длительности или даты начала сдвигает последующие итерации.", + "IterationErrorOverlap": "Итерация не может начинаться раньше окончания предыдущей.", + "IterationErrorInvalidDuration": "Длительность должна быть целым числом дней, не менее 1.", + "IterationErrorEmptyLabel": "Название не может быть пустым.", + "MoveItemsTo": "Переместить задачи в…", + "IterationRollupSummary": "{done} из {count} выполнено · {estimation} ч", + "ProjectFieldTypeIteration": "Итерация" }, "status": {} } diff --git a/plugins/tracker-assets/lang/tr.json b/plugins/tracker-assets/lang/tr.json index a312da0efa..253a95406f 100644 --- a/plugins/tracker-assets/lang/tr.json +++ b/plugins/tracker-assets/lang/tr.json @@ -603,7 +603,34 @@ "SearchEmptyTitle": "\"{query}\" için sorun bulunamadı", "SearchEmptyActiveFilters": "Aktif filtreler: {filters}", "SearchEmptyClearFilters": "Filtresiz ara", - "SearchEmptyAllProjects": "Tüm projelerde ara" + "SearchEmptyAllProjects": "Tüm projelerde ara", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration" }, "status": {} } diff --git a/plugins/tracker-assets/lang/zh.json b/plugins/tracker-assets/lang/zh.json index f8dd740a12..50de0a74e4 100644 --- a/plugins/tracker-assets/lang/zh.json +++ b/plugins/tracker-assets/lang/zh.json @@ -382,7 +382,34 @@ "SearchEmptyTitle": "未找到与 \"{query}\" 匹配的问题", "SearchEmptyActiveFilters": "活动筛选器:{filters}", "SearchEmptyClearFilters": "不使用筛选器搜索", - "SearchEmptyAllProjects": "在所有项目中搜索" + "SearchEmptyAllProjects": "在所有项目中搜索", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration" }, "status": {} } diff --git a/plugins/tracker-resources/src/__tests__/issueFilter.test.ts b/plugins/tracker-resources/src/__tests__/issueFilter.test.ts index 98fede7585..dc6cfeaaed 100644 --- a/plugins/tracker-resources/src/__tests__/issueFilter.test.ts +++ b/plugins/tracker-resources/src/__tests__/issueFilter.test.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: EPL-2.0 // -import { ProjectFieldType, type ProjectField } from '@hcengineering/tracker' +import { ProjectFieldType, toIterationRanges, type ProjectField } from '@hcengineering/tracker' import { filterGrammar } from '@hcengineering/view-resources' import { buildIssueFilterSchema, customFilterToQuery, fieldFilterName } from '../issueFilter' import { buildFiltersPredicate, endOfDay, startOfDay, type CustomFieldFilter } from '../projectFields/query' @@ -170,3 +170,87 @@ describe('customFilterToQuery', () => { } }) }) + +describe('iteration fields in the filter', () => { + const day = (m: number, d: number): number => new Date(2026, m - 1, d).getTime() + const sprintField = field('sprint', 'Sprint', ProjectFieldType.Iteration) + const iterations: any[] = [ + { _id: 'it1', field: 'sprint', label: 'Sprint 1', number: 1, startDate: day(10, 5), duration: 7 }, + { _id: 'it2', field: 'sprint', label: 'Sprint 2', number: 2, startDate: day(10, 12), duration: 7 }, + { _id: 'brk', field: 'sprint', label: 'Break', number: 0, startDate: day(10, 19), duration: 7, isBreak: true }, + { _id: 'it3', field: 'sprint', label: 'Sprint 3', number: 3, startDate: day(10, 26), duration: 7 }, + { _id: 'other', field: 'elsewhere', label: 'Other', number: 1, startDate: day(10, 5), duration: 7 } + ] + const sprintSchema = buildIssueFilterSchema({ + statuses: [], + priorities: [], + assignees: [], + components: [], + milestones: [], + labels: [], + labelRefs: [], + customFields: [sprintField], + iterations, + noParentId: 'no-parent' + }) + // Wednesday of the second iteration + const now = new Date(2026, 9, 14, 12).getTime() + const ctx = { + now, + iterations: (key: string) => (key === 'sprint' ? toIterationRanges(iterations.filter((it) => it.field === 'sprint')) : []) + } + const issues: any[] = [ + { _id: 'a', customFields: { sprint: 'it1' } }, + { _id: 'b', customFields: { sprint: 'it2' } }, + { _id: 'c', customFields: { sprint: 'it3' } }, + { _id: 'd' } + ] + const match = (text: string): string[] => { + const parsed = parseFilter(text, sprintSchema) + if (!parsed.ok) throw new Error(`${text}: ${parsed.error.message}`) + const predicate = filterGrammar.createPredicate(parsed.value, ctx) + return issues.filter((i) => predicate(i)).map((i) => i._id) + } + + it('offers the iterations of the field, without breaks, as options', () => { + const spec = sprintSchema.find((f) => f.name === 'sprint') + expect(spec?.type).toBe('iteration') + expect(spec?.options).toEqual([ + { id: 'it1', name: 'Sprint 1' }, + { id: 'it2', name: 'Sprint 2' }, + { id: 'it3', name: 'Sprint 3' } + ]) + }) + + it('resolves @current, @next and @previous against the iterations of the project', () => { + expect(match('sprint:@current')).toEqual(['b']) + expect(match('sprint:@next')).toEqual(['c']) + expect(match('sprint:@previous')).toEqual(['a']) + expect(match('sprint:@current,@next')).toEqual(['b', 'c']) + expect(match('sprint:@current+1')).toEqual(['c']) + expect(match('sprint:>@previous')).toEqual(['b', 'c']) + expect(match('-sprint:@current')).toEqual(['a', 'c', 'd']) + expect(match('no:sprint')).toEqual(['d']) + }) + + it('matches by title', () => { + expect(match('sprint:"Sprint 1"')).toEqual(['a']) + }) + + it('finds no current iteration during a break', () => { + const during = { ...ctx, now: new Date(2026, 9, 21, 12).getTime() } + const parsed = parseFilter('sprint:@current', sprintSchema) + if (!parsed.ok) throw new Error('parse') + const predicate = filterGrammar.createPredicate(parsed.value, during) + expect(issues.filter((i) => predicate(i))).toEqual([]) + }) + + it('names the picked iterations when folding the rules into a filter string', () => { + const rules: CustomFieldFilter[] = [ + { id: '1', fieldKey: 'sprint', operator: 'anyOf', value: ['@current', 'it1'] } + ] + const text = customFilterToQuery(rules, new Map([['sprint', sprintField]]), () => iterations.filter((it) => it.field === 'sprint')) + expect(text).toBe('sprint:@current,"Sprint 1"') + expect(match(text)).toEqual(['a', 'b']) + }) +}) diff --git a/plugins/tracker-resources/src/bulkEdit/__tests__/issueCells.test.ts b/plugins/tracker-resources/src/bulkEdit/__tests__/issueCells.test.ts index b9b3c43a11..826446ecfb 100644 --- a/plugins/tracker-resources/src/bulkEdit/__tests__/issueCells.test.ts +++ b/plugins/tracker-resources/src/bulkEdit/__tests__/issueCells.test.ts @@ -53,6 +53,13 @@ const fields = [ field({ key: 'sprint', type: ProjectFieldType.Iteration }) ] +const day = (m: number, d: number): number => new Date(2026, m - 1, d).getTime() +const iterations: any[] = [ + { _id: 'it1', label: 'Sprint 1', number: 1, startDate: day(1, 5), duration: 7 }, + { _id: 'it2', label: 'Sprint 2', number: 2, startDate: day(1, 12), duration: 7 }, + { _id: 'brk', label: 'Holiday', number: 0, startDate: day(1, 19), duration: 7, isBreak: true } +] + let refs: Record = {} let allowed = true @@ -74,6 +81,7 @@ const lookups: IssueCellLookups = { ], labelRefs: (id) => refs[id] ?? [], fields: new Map(fields.map((f) => [f.key, f])), + iterations: () => iterations, canEdit: () => allowed, now: () => new Date(2026, 0, 10, 12).getTime() } @@ -91,13 +99,43 @@ beforeEach(() => { allowed = true }) +describe('iteration cells', () => { + // The lookups pin "now" to 2026-01-10, which is in Sprint 1 + it('shows the title and parses titles case-insensitively', () => { + const d = issue({ _id: 'i1', customFields: { sprint: 'it2' } }) + expect(column('cf_sprint')?.format(d)).toBe('Sprint 2') + expect(edit('cf_sprint', d, 'sprint 1')).toMatchObject({ + ok: true, + value: [{ kind: 'update', after: { customFields: { sprint: 'it1' } } }] + }) + }) + + it('understands the filter keywords', () => { + const d = issue({ _id: 'i1' }) + const after = (text: string): unknown => { + const res = edit('cf_sprint', d, text) + return res.ok ? (res.value[0] as any).after.customFields : res + } + expect(after('@current')).toEqual({ sprint: 'it1' }) + expect(after('@next')).toEqual({ sprint: 'it2' }) + expect(after('@current+1')).toEqual({ sprint: 'it2' }) + expect(edit('cf_sprint', d, '@previous')).toEqual({ ok: false, reason: 'unknown' }) + }) + + it('refuses breaks and unknown titles, and clears on empty text', () => { + const d = issue({ _id: 'i1', customFields: { sprint: 'it1', other: 1 } }) + expect(edit('cf_sprint', d, 'Holiday')).toEqual({ ok: false, reason: 'unknown' }) + expect(edit('cf_sprint', d, 'nope')).toEqual({ ok: false, reason: 'unknown' }) + expect(edit('cf_sprint', d, '')).toMatchObject({ ok: true, value: [{ after: { customFields: { other: 1 } } }] }) + }) +}) + describe('issue cell columns', () => { it('has no column for read-only cells', () => { expect(column('issue')).toBeUndefined() expect(column('modified')).toBeUndefined() expect(column('cf_missing')).toBeUndefined() - // Iteration fields are not edited here - expect(column('cf_sprint')).toBeUndefined() + expect(column('cf_sprint')).toBeDefined() }) it('sets the status by name and refuses to clear it', () => { diff --git a/plugins/tracker-resources/src/bulkEdit/issueCells.ts b/plugins/tracker-resources/src/bulkEdit/issueCells.ts index 63ffae547b..15c215ac89 100644 --- a/plugins/tracker-resources/src/bulkEdit/issueCells.ts +++ b/plugins/tracker-resources/src/bulkEdit/issueCells.ts @@ -5,8 +5,14 @@ import { generateId, type DocumentUpdate, type TxOperations } from '@hcengineering/core' import tags from '@hcengineering/tags' -import type { Issue, ProjectField } from '@hcengineering/tracker' -import { IssuePriority, ProjectFieldType, getFieldValue } from '@hcengineering/tracker' +import type { Issue, Iteration, ProjectField } from '@hcengineering/tracker' +import { + IssuePriority, + ProjectFieldType, + getAssignableIterations, + getFieldValue, + resolveRelativeIteration +} from '@hcengineering/tracker' import { tableEdit } from '@hcengineering/view-resources' import { mergeCustomFieldValue } from '../projectFields/registry' @@ -37,6 +43,8 @@ export interface IssueCellLookups { labelRefs: (issueId: string) => IssueLabelRef[] // Custom fields by field key fields: ReadonlyMap + // Iterations of an iteration field + iterations?: (field: ProjectField) => readonly Iteration[] // Whether the current user may change the attribute of the issue canEdit?: (issue: Issue, attribute: string) => boolean now?: () => number @@ -169,6 +177,31 @@ function fieldOptions (field: ProjectField): Array> { return (field.options ?? []).map((o) => ({ id: o.value, label: o.label })) } +// `@current`, `@next` and `@previous`, optionally with `+N` / `-N`, as in the filter string +const ITERATION_KEYWORD = /^@(current|next|previous)(?:([+-])(\d+))?$/i + +function iterationOptions (field: ProjectField, lookups: IssueCellLookups): Array> { + return getAssignableIterations(lookups.iterations?.(field) ?? []).map((it) => ({ id: it._id, label: it.label })) +} + +function parseIteration ( + field: ProjectField, + text: string, + lookups: IssueCellLookups, + now: number +): tableEdit.ParseResult { + const m = ITERATION_KEYWORD.exec(text.trim()) + if (m === null) return tableEdit.parseOptionValue(text, iterationOptions(field, lookups)) + const offset = m[2] === undefined ? 0 : Number(m[3]) * (m[2] === '-' ? -1 : 1) + const target = resolveRelativeIteration( + lookups.iterations?.(field) ?? [], + m[1].toLowerCase() as 'current' | 'next' | 'previous', + offset, + now + ) + return target === undefined ? { ok: false, reason: 'unknown' } : { ok: true, value: target._id } +} + function customFieldColumn (field: ProjectField, lookups: () => IssueCellLookups): CellColumn | undefined { const key = customFieldColumnKey(field.key) const read = (issue: Issue): ReturnType => getFieldValue(issue.customFields, field) @@ -196,8 +229,11 @@ function customFieldColumn (field: ProjectField, lookups: () => IssueCellLookups format = (issue) => tableEdit.formatMultiOptionValue(read(issue) as string[] | null, fieldOptions(field)) parse = (text) => tableEdit.parseMultiOptionValue(text, fieldOptions(field)) break + case ProjectFieldType.Iteration: + format = (issue) => tableEdit.formatOptionValue(read(issue) as string | null, iterationOptions(field, lookups())) + parse = (text, now) => parseIteration(field, text, lookups(), now) + break default: - // Iteration values are managed by the iteration features return undefined } return { diff --git a/plugins/tracker-resources/src/components/issues/IssuesView.svelte b/plugins/tracker-resources/src/components/issues/IssuesView.svelte index 4e2c4c2ef7..f44d9c1ddd 100644 --- a/plugins/tracker-resources/src/components/issues/IssuesView.svelte +++ b/plugins/tracker-resources/src/components/issues/IssuesView.svelte @@ -6,7 +6,7 @@ import tags, { TagElement, TagReference } from '@hcengineering/tags' import task, { getTaskTypeStates } from '@hcengineering/task' import { taskTypeStore } from '@hcengineering/task-resources' - import { Issue, Project, TrackerEvents } from '@hcengineering/tracker' + import { Issue, Iteration, Project, TrackerEvents, toIterationRanges } from '@hcengineering/tracker' import { Button, IconAdd, @@ -46,6 +46,7 @@ import { readable } from 'svelte/store' import { createIssueCellColumns, runIssueOps, type IssueCellLookups, type IssueLabelRef } from '../../bulkEdit/issueCells' import { buildIssueFilterSchema, customFilterToQuery, type NamedOption } from '../../issueFilter' + import { iterationsByFieldKey, sharedIterationsStore } from '../../iterations/iterationsStore' import tracker from '../../plugin' import CustomFieldFilterButton from '../../projectFields/CustomFieldFilterButton.svelte' import { createCustomFieldViewExtension, customFieldFilterStore } from '../../projectFields/customFieldView' @@ -105,10 +106,14 @@ // The values live in an untyped record, so all of it runs on the client over a bounded scan. const emptyRegistry = readable(buildRegistry([])) const noFilters = readable([]) + const noIterations = readable([]) const scanQuery = createQuery() $: project = space as Ref | undefined $: registry = project !== undefined ? sharedProjectFieldsStore(project) : emptyRegistry + $: iterationsStore = project !== undefined ? sharedIterationsStore(project) : noIterations + // Iterations of each iteration field, by the key of the field + $: iterationsByKey = iterationsByFieldKey($iterationsStore, $registry.fields) $: filtersStore = project !== undefined ? customFieldFilterStore(project) : noFilters $: filters = $filtersStore $: hasFilterableFields = $registry.fields.some((f) => isFilterableType(f.type)) @@ -216,6 +221,7 @@ labels: labelElements, labelRefs: (id) => labelRefsByIssue.get(id) ?? [], fields: $registry.byKey, + iterations: (field) => iterationsByKey.get(field.key) ?? [], canEdit: canChangeIssueAttribute } const cellAdapter = { @@ -240,6 +246,7 @@ labels, labelRefs, customFields: $registry.fields, + iterations: $iterationsStore, noParentId: tracker.ids.NoParent }) @@ -249,7 +256,8 @@ me: getCurrentEmployee() as string, closedStatuses, noParentId: tracker.ids.NoParent as string, - iterations: () => [] + // `@current`, `@next` and `@previous` resolve against the iterations of the project; breaks never match + iterations: (fieldKey: string) => toIterationRanges(iterationsByKey.get(fieldKey) ?? []) } satisfies filterGrammar.FilterContext $: reservedKeys = new Set(Object.keys(resultQuery).filter((k) => !k.startsWith('$'))) $: stringFilterActive = filterQuery.trim() !== '' @@ -264,7 +272,7 @@ function applyFilterQuery (e: CustomEvent): void { let next = e.detail if (project !== undefined && next.trim() !== '' && activeFilterCount(filters) > 0) { - next = filterGrammar.joinAnd(next, customFilterToQuery(filters, $registry.byKey)) + next = filterGrammar.joinAnd(next, customFilterToQuery(filters, $registry.byKey, (key) => iterationsByKey.get(key) ?? [])) customFieldFilterStore(project).set([]) } filterQuery = next @@ -302,7 +310,10 @@ // Above the limit custom-field filter/sort/group is off; it is never applied to a truncated set $: overLimit = needsScan && scanReady && exceedsScanLimit(scanned.length, scanLimit) - $: legacyPredicate = buildFiltersPredicate($registry.byKey, filtersActive ? filters : []) + $: legacyPredicate = buildFiltersPredicate($registry.byKey, filtersActive ? filters : [], { + iterations: (key) => iterationsByKey.get(key) ?? [], + now: filterCtx.now + }) $: predicate = (issue: Partial): boolean => legacyPredicate(issue) && residualPredicate(issue) // What the server narrows to: the view's chips and search plus the indexable part of the filter string $: serverQuery = { ...resultQuery, ...stringServerQuery } as DocumentQuery @@ -336,7 +347,13 @@ // Always installed (even without fields) so a saved custom group/order key never reaches the server $: clientViewExtension.set( - createCustomFieldViewExtension({ registry: $registry, scanLimit, disabled: overLimit, emptyLabels }) + createCustomFieldViewExtension({ + registry: $registry, + scanLimit, + disabled: overLimit, + emptyLabels, + iterations: $iterationsStore + }) ) onDestroy(() => { clientViewExtension.set(undefined) diff --git a/plugins/tracker-resources/src/issueFilter.ts b/plugins/tracker-resources/src/issueFilter.ts index b39b3651d3..b33da4241c 100644 --- a/plugins/tracker-resources/src/issueFilter.ts +++ b/plugins/tracker-resources/src/issueFilter.ts @@ -3,8 +3,8 @@ // SPDX-License-Identifier: EPL-2.0 // -import type { ProjectField } from '@hcengineering/tracker' -import { ProjectFieldType } from '@hcengineering/tracker' +import type { Iteration, ProjectField } from '@hcengineering/tracker' +import { getAssignableIterations, ProjectFieldType } from '@hcengineering/tracker' import type { filterGrammar } from '@hcengineering/view-resources' import { isFilterComplete, type CustomFieldFilter } from './projectFields/query' @@ -31,6 +31,8 @@ export interface IssueFilterSchemaInput { // Tag references: which issue carries which label labelRefs: Array<{ issue: string, label: string }> customFields: ProjectField[] + // Iterations of the iteration fields among the custom fields + iterations?: readonly Iteration[] // Value of Issue.attachedTo for an issue without a parent noParentId: string } @@ -165,7 +167,12 @@ export function buildIssueFilterSchema (input: IssueFilterSchemaInput): FieldSpe source: 'custom', key: field.key, options: - field.options !== undefined ? field.options.map((o) => ({ id: o.value, name: o.label })) : undefined + field.type === ProjectFieldType.Iteration + ? getAssignableIterations((input.iterations ?? []).filter((it) => it.field === field._id)).map((it) => ({ + id: it._id, + name: it.label + })) + : field.options?.map((o) => ({ id: o.value, name: o.label })) }) } return schema @@ -190,7 +197,9 @@ function quote (text: string): string { */ export function customFilterToQuery ( rules: readonly CustomFieldFilter[], - fields: ReadonlyMap> + fields: ReadonlyMap>, + // Iterations of an iteration field by its key, to name the picked ones + iterationsOf: (fieldKey: string) => ReadonlyArray> = () => [] ): string { const terms: string[] = [] for (const rule of rules) { @@ -235,7 +244,12 @@ export function customFilterToQuery ( break } case 'anyOf': { - const labels = (value as string[]).map((id) => field.options?.find((o) => o.value === id)?.label ?? id) + const labels = (value as string[]).map( + (id) => + (field.type === ProjectFieldType.Iteration + ? iterationsOf(field.key).find((it) => it._id === id)?.label + : field.options?.find((o) => o.value === id)?.label) ?? id + ) terms.push(`${name}:${labels.map(quote).join(',')}`) break } diff --git a/plugins/tracker-resources/src/iterations/IterationFieldEditor.svelte b/plugins/tracker-resources/src/iterations/IterationFieldEditor.svelte new file mode 100644 index 0000000000..3c1ae759fd --- /dev/null +++ b/plugins/tracker-resources/src/iterations/IterationFieldEditor.svelte @@ -0,0 +1,71 @@ + + + + diff --git a/plugins/tracker-resources/src/iterations/IterationGroupExtras.svelte b/plugins/tracker-resources/src/iterations/IterationGroupExtras.svelte new file mode 100644 index 0000000000..4fe3c41575 --- /dev/null +++ b/plugins/tracker-resources/src/iterations/IterationGroupExtras.svelte @@ -0,0 +1,86 @@ + + + + + +
+ + + {#if value !== undefined && rollup.count > 0} + + {/if} +
diff --git a/plugins/tracker-resources/src/iterations/IterationGroupHeader.svelte b/plugins/tracker-resources/src/iterations/IterationGroupHeader.svelte new file mode 100644 index 0000000000..14c6e51897 --- /dev/null +++ b/plugins/tracker-resources/src/iterations/IterationGroupHeader.svelte @@ -0,0 +1,27 @@ + + + +{iteration?.label ?? value ?? ''} +{#if iteration !== undefined} + {range} + {#if current} + + {/if} +{/if} diff --git a/plugins/tracker-resources/src/iterations/IterationPresenter.svelte b/plugins/tracker-resources/src/iterations/IterationPresenter.svelte new file mode 100644 index 0000000000..de8f7a65f6 --- /dev/null +++ b/plugins/tracker-resources/src/iterations/IterationPresenter.svelte @@ -0,0 +1,28 @@ + + + + + {iteration.label} + {#if current} + + {/if} + diff --git a/plugins/tracker-resources/src/iterations/IterationPresenter.svelte.x b/plugins/tracker-resources/src/iterations/IterationPresenter.svelte.x new file mode 100644 index 0000000000..de8f7a65f6 --- /dev/null +++ b/plugins/tracker-resources/src/iterations/IterationPresenter.svelte.x @@ -0,0 +1,28 @@ + + + + + {iteration.label} + {#if current} + + {/if} + diff --git a/plugins/tracker-resources/src/iterations/IterationRow.svelte b/plugins/tracker-resources/src/iterations/IterationRow.svelte new file mode 100644 index 0000000000..86b529c4b1 --- /dev/null +++ b/plugins/tracker-resources/src/iterations/IterationRow.svelte @@ -0,0 +1,158 @@ + + + +
+
+ +
+ commitStart(e.currentTarget.value)} + /> +
+ +
+ + dispatch('insertBreak')} + /> + dispatch('remove')} + /> +
+ + diff --git a/plugins/tracker-resources/src/iterations/IterationsEditor.svelte b/plugins/tracker-resources/src/iterations/IterationsEditor.svelte new file mode 100644 index 0000000000..93550d8830 --- /dev/null +++ b/plugins/tracker-resources/src/iterations/IterationsEditor.svelte @@ -0,0 +1,130 @@ + + + +
+
+ {#if upcoming.length === 0} +
+ {/if} + {#each upcoming as iteration (iteration._id)} + change(iteration, e.detail)} + on:insertBreak={() => add(iteration._id, true)} + on:remove={() => remove(iteration)} + /> + {/each} +
+
+ + {#if completed.length > 0} + + {#if showCompleted} + {#each completed as iteration (iteration._id)} + change(iteration, e.detail)} + on:insertBreak={() => add(iteration._id, true)} + on:remove={() => remove(iteration)} + /> + {/each} + {/if} + {/if} + + {#if error !== undefined} +
+ {/if} +
+
+ + diff --git a/plugins/tracker-resources/src/iterations/actions.ts b/plugins/tracker-resources/src/iterations/actions.ts new file mode 100644 index 0000000000..ea58f97efa --- /dev/null +++ b/plugins/tracker-resources/src/iterations/actions.ts @@ -0,0 +1,93 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Doc, Ref, TxOperations } from '@hcengineering/core' +import type { Issue, Iteration, ProjectField } from '@hcengineering/tracker' +import { planAddIteration, planIterationChange, planMoveItems, type AddIterationOptions, type IterationChange, type IterationPlanError } from '@hcengineering/tracker' + +import tracker from '../plugin' + +/** + * Move the given issues from one iteration of a field to another one (or to none). Issues that are + * not in `from` are left alone. The change is atomic. Returns the number of issues that were moved. + */ +export async function moveIterationItems ( + client: TxOperations, + issues: ReadonlyArray>, + field: Pick, + from: string, + to: string | null +): Promise { + if (issues.length === 0) return 0 + // The documents of a list group are projections: the full issues are needed to update them + const full = await client.findAll(tracker.class.Issue, { _id: { $in: issues.map((i) => i._id as Ref) } }) + const plan = planMoveItems(full, field.key, from, to) + if (plan.length === 0) return 0 + const batch = client.apply() + for (const { issue, customFields } of plan) { + await batch.updateCollection( + issue._class, + issue.space, + issue._id, + issue.attachedTo, + issue.attachedToClass, + issue.collection, + { customFields } + ) + } + const res = await batch.commit() + if (!res.result) throw new Error('The items could not be moved: the data was changed meanwhile') + return plan.length +} + +/** + * Apply a change of one iteration (title, start, duration) and the shift of the later ones in one batch. + * Returns the reason when the change is not allowed. + */ +export async function changeIteration ( + client: TxOperations, + iterations: readonly Iteration[], + id: Ref, + change: IterationChange +): Promise { + const plan = planIterationChange(iterations, id, change) + if (!plan.ok) return plan.error + if (plan.updates.length === 0) return undefined + const space = iterations.find((it) => it._id === id)?.space + if (space === undefined) return 'unknown' + const batch = client.apply() + for (const u of plan.updates) { + await batch.updateDoc(tracker.class.Iteration, space, u.id, u.update) + } + await batch.commit() + return undefined +} + +/** + * Add an iteration or a break to a field (see `planAddIteration`) and shift what follows it, in one batch. + */ +export async function addIteration ( + client: TxOperations, + field: Pick, + iterations: readonly Iteration[], + options: AddIterationOptions +): Promise { + const plan = planAddIteration(iterations, options) + if (!plan.ok) return plan.error + const batch = client.apply() + for (const u of plan.updates) { + await batch.updateDoc(tracker.class.Iteration, field.space, u.id, u.update) + } + await batch.createDoc(tracker.class.Iteration, field.space, { ...plan.draft, field: field._id }) + await batch.commit() + return undefined +} + +/** + * Delete an iteration. The server removes it from the issues that were in it. + */ +export async function removeIteration (client: TxOperations, iteration: Iteration): Promise { + await client.removeDoc(tracker.class.Iteration, iteration.space, iteration._id) +} diff --git a/plugins/tracker-resources/src/iterations/iterationsStore.ts b/plugins/tracker-resources/src/iterations/iterationsStore.ts new file mode 100644 index 0000000000..5f1d9350d5 --- /dev/null +++ b/plugins/tracker-resources/src/iterations/iterationsStore.ts @@ -0,0 +1,61 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { createQuery } from '@hcengineering/presentation' +import type { Ref } from '@hcengineering/core' +import type { Iteration, Project, ProjectField } from '@hcengineering/tracker' +import { readable, type Readable } from 'svelte/store' + +import tracker from '../plugin' + +/** + * Live list of the iterations of all Iteration fields of a project. The query is started on first + * subscription and stopped when the last subscriber leaves. + */ +export function iterationsStore (project: Ref): Readable { + return readable([], (set) => { + const query = createQuery(true) + query.query(tracker.class.Iteration, { space: project }, (result) => { + set(result) + }) + return () => { + query.unsubscribe() + } + }) +} + +const sharedStores = new Map, Readable>() + +/** + * Same as `iterationsStore`, but one store (and so one live query) per project is shared by all callers. + * Use it where many components need the iterations at once, e.g. one cell per list row. + */ +export function sharedIterationsStore (project: Ref): Readable { + let store = sharedStores.get(project) + if (store === undefined) { + store = iterationsStore(project) + sharedStores.set(project, store) + } + return store +} + +/** + * The iterations of one field. + */ +export function iterationsOfField (iterations: readonly Iteration[], field: Pick): Iteration[] { + return iterations.filter((it) => it.field === field._id) +} + +/** + * The iterations of every field, by field key. + */ +export function iterationsByFieldKey ( + iterations: readonly Iteration[], + fields: ReadonlyArray> +): Map { + const res = new Map() + for (const field of fields) res.set(field.key, iterationsOfField(iterations, field)) + return res +} diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index 9ce6281116..017758a760 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -65,6 +65,33 @@ export default mergeIds(trackerId, tracker, { ProjectFieldTypeDate: '' as IntlString, ProjectFieldTypeSingleSelect: '' as IntlString, ProjectFieldTypeMultiSelect: '' as IntlString, + Iteration: '' as IntlString, + Iterations: '' as IntlString, + IterationDuration: '' as IntlString, + IterationBreak: '' as IntlString, + IterationTitlePlaceholder: '' as IntlString, + IterationStartsOn: '' as IntlString, + IterationDurationDays: '' as IntlString, + IterationDurationWeeks: '' as IntlString, + IterationCurrentMarker: '' as IntlString, + IterationCurrent: '' as IntlString, + IterationNext: '' as IntlString, + IterationPrevious: '' as IntlString, + NoIteration: '' as IntlString, + NoIterations: '' as IntlString, + AddIteration: '' as IntlString, + InsertIterationBreak: '' as IntlString, + DeleteIteration: '' as IntlString, + DeleteIterationConfirm: '' as IntlString, + UpcomingIterations: '' as IntlString, + CompletedIterations: '' as IntlString, + IterationSavedImmediately: '' as IntlString, + IterationErrorOverlap: '' as IntlString, + IterationErrorInvalidDuration: '' as IntlString, + IterationErrorEmptyLabel: '' as IntlString, + MoveItemsTo: '' as IntlString, + IterationRollupSummary: '' as IntlString, + ProjectFieldTypeIteration: '' as IntlString, AddProjectFieldOption: '' as IntlString, ProjectFieldOptionLabel: '' as IntlString, ProjectFieldOptionDescription: '' as IntlString, diff --git a/plugins/tracker-resources/src/projectFields/CustomFieldColumn.svelte b/plugins/tracker-resources/src/projectFields/CustomFieldColumn.svelte index 86dca73ac7..d8788be666 100644 --- a/plugins/tracker-resources/src/projectFields/CustomFieldColumn.svelte +++ b/plugins/tracker-resources/src/projectFields/CustomFieldColumn.svelte @@ -52,7 +52,7 @@ } -{#if field !== undefined && field.type !== ProjectFieldType.Iteration} +{#if field !== undefined}
diff --git a/plugins/tracker-resources/src/projectFields/CustomFieldFilterPopup.svelte b/plugins/tracker-resources/src/projectFields/CustomFieldFilterPopup.svelte index 2ec3e85e71..68bd467ec6 100644 --- a/plugins/tracker-resources/src/projectFields/CustomFieldFilterPopup.svelte +++ b/plugins/tracker-resources/src/projectFields/CustomFieldFilterPopup.svelte @@ -5,15 +5,17 @@ @@ -350,6 +427,36 @@
{/if} + {#if draft.type === ProjectFieldType.Iteration} + {#if draft.id === undefined} +
+
+
+
+ {:else if currentField !== undefined} + + {/if} + {/if} + {#if supportsDefault(draft.type)}