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