This document is for AI coding agents contributing to this repository. It explains project purpose, architecture, invariants, and safe extension patterns.
An implementation of a Model Context Protocol (MCP) server that exposes Creatio CRM to MCP-compatible AI clients (Claude Desktop, ChatGPT Connectors, GitHub Copilot, etc.).
Primary goals:
- Provide stable tool surface for CRUD, schema discovery, business process execution.
- Enforce safe data operations (especially Activities ownership rules, date/time UTC handling).
- Offer prompts that guide LLMs to use Creatio correctly.
src/
creatio/ ← Low-level Creatio API client & auth providers
contracts/ ← provider interfaces (CrudProvider, ProcessProvider, … — the "ports")
services/ ← provider impls; CRUD backends in odata/ + dataservice/, plus process, sys-settings, …
engines/ ← domain layer over the contracts (readonly guard + audit; see below)
auth/ ← auth providers (legacy / OAuth2 client-credentials / stateless Bearer / broker) + core contract (contracts/headers/identity/constants)
server/ ← MCP server + HTTP layer (bearer edge, broker OAuth server, handlers)
mcp/ ← MCP tool descriptors, prompts, filters builder
tool-preparer.ts ← ToolPreparer/ToolRegistrar contracts (env-gated tools)
creatio-rest.ts ← shared REST/sys-setting contracts + helpers for capability clients
dataforge/ ← DataForge capability: client + tool preparer
globalsearch/ ← Global Search capability: client + tool preparer
bearer/ ← stateless per-request credential edge: Bearer OR forwarded cookie session (delegated: RFC 9728 + fail-fast expiry; gateway: trust)
oauth/ ← broker mode: the MCP's own OAuth 2.1 AS (DCR + /authorize + /token, JWT + PKCE)
sessions/ ← per-process MCP session/transport lifecycle + per-user Creatio token store (broker only)
utils/ ← Reusable helpers (env, network, context)
types/ ← Shared TypeScript interfaces & DTO shapes
Naming:
creatio/contracts/holds the interfaces ("ports"),creatio/services/the implementations. The per-process session store lives in top-levelsessions/(distinct fromcreatio/services/).
Two entry points, one Server core:
- HTTP web service —
src/index.ts(npm start) →HttpServeronCREATIO_MCP_PORT(default 3000), MCP over Streamable HTTP at/mcp. The multi-user transport, serving three HTTP auth modes:broker(the MCP is its own OAuth 2.1 AS and holds users' Creatio tokens),delegated(default — client brings the token), andgateway(a Control-Plane injects it). Config from env (getCreatioClientConfig); HTTP defaults todelegatedwhen no auth is set. - stdio —
src/cli.ts(the npmbinmcp-creatio;npm run start:stdio) →StdioServerTransport. For a local client (Claude Desktop) that spawns the process. CLI args map onto the same env vars.
One
McpServerper session (multi-user invariant).Server.createSessionServer(baseUrlOverride?)builds a freshMcpServerbound to each transport — a singleMcpServerconnects to only one transport, so a shared instance would reject the 2nd concurrent session'sconnect(). The STATIC tool/descriptor maps are user-agnostic (identity is read from the per-requestAsyncLocalStoragecontext at call time) and shared across sessions.stopAll()closes them on shutdown.Per-tenant tool isolation (multi-tenant invariant). Optional-capability verdicts and the dynamic tools they register (DataForge, Global Search, published tools) are held PER TENANT in
src/server/mcp/tenant-tool-registry.ts(TenantToolRegistry→TenantToolState), keyed by the effective Creatio base URL — the gatewayX-Creatio-Base-Urloverride, elseCREATIO_BASE_URL(sentinelDEFAULT_TENANT_KEYfor all single-tenant modes, so their behavior is unchanged). The probe runs once PER TENANT and a discovered tool is registered only into that tenant's live session servers, so tenant A's capabilities/published tools never leak to tenant B on a sharedgatewaydeployment. The registry pools state with idle-TTL + LRU eviction and never evicts a tenant that still has a live session.createSessionServer/ensureCapabilitiesProbed/_describeEntityresolve the tenant from the request context;releaseSessionServer/stopAllgo through the registry.
The Docker image (multi-stage, node:24-alpine, runs the built dist/ — not ts-node)
serves HTTP by default; set MCP_TRANSPORT=stdio (run with docker run -i) to switch. The
docker-entrypoint.sh selects dist/index.js vs dist/cli.js. Both transports read the same
env. The .github/workflows/docker-publish.yml builds multi-arch on main/v* tags and syncs
the README to the Docker Hub overview.
Key flows:
- Client authenticates (HTTP:
broker— MCP-issued token, or per-request Bearer — delegated/gateway; stdio: client-credentials or legacy). - MCP server registers tools using descriptors from
server/mcp/tools-data.ts. - Tool handlers call into
CreatioEngineManager, which resolves aCreatioServiceContext(built fromsrc/creatio/services/*) and delegates work to the appropriate provider (CRUD, process, sys-settings, user). - Responses are normalized into MCP content blocks.
CreatioServiceContext
├─ CreatioAuthManager → selects the provider for CREATIO_MCP_AUTH_MODE (legacy / client-credentials / stateless Bearer / broker)
├─ CreatioHttpClient → transport + logging + retry + header helpers
│ └─ request(op, url, build, onSuccess, {errorPrefix, logContext}) → the standard
│ timed call (wraps executeWithTiming + handleErrorResponse); prefer it in providers
├─ createCrudProvider(config.crudBackend, …) → selects the CRUD backend per-deployment
│ ├─ DataServiceCrudProvider (DEFAULT) → SelectQuery/Insert/Update/Delete via
│ │ /0/DataService/json/SyncReply/*; schema via RuntimeEntitySchemaRequest +
│ │ VwSysSchemaInWorkspace (services/dataservice/*)
│ └─ ODataCrudProvider (CREATIO_MCP_CRUD_BACKEND=odata) → http + ODataMetadataStore
│ (services/odata/*)
├─ ProcessServiceProvider → POSTs to ProcessEngineService
├─ SysSettingsServiceProvider → DataService JSON endpoint
├─ FeatureServiceProvider → /rest/FeatureService/ClearFeaturesCacheForAllUsers
├─ AdminOperationServiceProvider → /rest/RightsService/{Upsert,Delete}AdminOperation[,Grantee]
├─ ConfigurationServiceProvider → generic /rest/<service>/<method> caller
└─ UserInfoProvider → UserInfoService for current user data
Usage pattern:
- Handlers never craft raw fetch calls. They work through provider interfaces exposed by the context (
provider.crud,provider.process, etc.). - If you need a new Creatio capability, add a dedicated provider (or extend an existing one) and wire it up inside
CreatioServiceContext. CreatioHttpClientshould stay transport-focused (auth headers, retries, timing). Keep endpoint-specific logic inside providers or a dedicated endpoint helper. Useclient.request(...)for the standard timed call instead of repeating theexecuteWithTiming+handleErrorResponseboilerplate.
The engines under src/creatio/engines/ are the domain seam ABOVE the provider interface, so cross-cutting policy is written once for every CRUD backend. BaseEngine._mutate(action, details, run) enforces readonly (throws ReadonlyModeError) and records an audit entry (log.audit) before delegating. Every new mutating engine method MUST route through _mutate; read methods stay direct pass-throughs. CreatioEngineManager owns the shared EngineEnv ({readonly, audit}); readonly is threaded from CREATIO_MCP_READONLY.
createCrudProvider(backend, deps) (src/creatio/services/crud-provider-factory.ts) picks the backend per-deployment from CREATIO_MCP_CRUD_BACKEND (dataservice default | odata), mirroring CreatioAuthManager. Both backends are fully implemented; each lives in its own folder (services/dataservice/*, services/odata/*). To add a backend: implement CrudProvider, add a branch in the factory — nothing above the interface changes.
The seam is a backend-agnostic query contract (src/creatio/contracts/query.ts): ReadQuery carries a structured FilterNode AST (NOT a dialect string), neutral columns/order/paging, and an odata bag for OData-only escape hatches (rawFilter, expand). read returns a normalized ReadResult { items, totalCount? }. Each backend owns a translator (Information Expert): ODataQueryTranslator (AST → $filter/$select/$orderby, incl. the lookup-nav XxxId→Xxx/Id + bare-GUID quirks) and DataServiceFilterTranslator/DataServiceQueryBuilder (AST → Filters tree + Columns, paths normalized Contact/Id → Contact.Id). DataService writes type ColumnValues from RuntimeEntitySchemaRequest metadata (authoritative dataValueType) with a heuristic fallback — the platform never infers the type from the JSON value. mcp/filters.ts only compiles the tool's {all,any} arg into a FilterNode (buildFilterNode) + parses orderBy; it knows nothing about either dialect.
These are exact platform contracts confirmed against core / the devkit ESQ. Each was a real
bug found in live regression; the values are load-bearing, not stylistic:
FilterComparisonTypenumbers (coreEntitySchemaQueryFilter.cs):IsNull=1, IsNotNull=2, Equal=3, NotEqual=4, Less=5, LessOrEqual=6, Greater=7, GreaterOrEqual=8, StartWith=9, Contain=11, EndWith=13. (Getting these wrong silently inverts gt/ge/lt/le.)IsNullFilterneeds an explicitisNullboolean (truefor is-null,falsefor is-not-null). The platformFilter.IsNulldefaults to TRUE, so omitting it makes every null-check an IS NULL (invertsisNotNull).- DateTime parameter value = JSON-quoted local-ISO WITHOUT
Z/offset ("2026-06-01T00:00:00", mirrors devkitɵencodeDate); the server interprets it in the user-profile timezone. A raw…Zstring 500s. (OData is the opposite: bareEdm.DateTimeOffsetWITHZ.) - Write coercion:
RuntimeEntitySchemaRequestreturns EXTENDED columndataValueTypecodes (e.g. MediumText=28); a Parameter must use the BASE type — map viatoParameterDataValueType(extended→base), else 500 "NotSupportedException". - Lookup columns: a scalar FK
XxxIdis not a DataService column. For filters/select,lookupIdPathnormalizesXxxId → Xxx.Id(and/→.); for writes, the FK key remaps to the logical lookup column (Type). A bare lookup (Type) returns its display value. top:0→ DataService rejectsFETCH 0; the builder omits paging and the provider returns[]without the row query (still runs the COUNT query).countuses a separate aggregation SelectQuery;list-entitiesisVwSysSchemaInWorkspacededuped by Name; reads project to the requested columns (DataService auto-adds primaryPhoto/display columns).
| Area | File(s) | Notes |
|---|---|---|
| Tool registration | src/server/mcp/server.ts |
Add/remove tool handlers; keep descriptors in separate file. Composition root that also runs tool preparers. |
| Tool schemas & text guidance | src/server/mcp/tools-data.ts |
Use zod schemas; detailed descriptions help AI reasoning. |
| Env-gated capabilities | src/server/mcp/tool-preparer.ts, src/server/mcp/dataforge/* |
ToolPreparer strategy: probe per tenant (in the request context), register tools only when the capability is available. DataForge is the reference impl. |
| Per-tenant tool isolation | src/server/mcp/tenant-tool-registry.ts |
TenantToolRegistry/TenantToolState: per-tenant capability verdicts + dynamic tools + live session servers, keyed by effective base URL, with idle-TTL + LRU. See the multi-tenant invariant in §2. |
| Query contract | src/creatio/contracts/query.ts |
Neutral ReadQuery/FilterNode/ReadResult; the seam both CRUD backends translate from. |
| Filters logic | src/server/mcp/filters.ts |
buildFilterNode compiles the tool {all,any} arg into the neutral FilterNode AST (+ parseOrderBy). NO dialect here. |
| Backend translators | src/creatio/services/{odata,dataservice}/* |
ODataQueryTranslator / DataServiceFilterTranslator+builder turn FilterNode into each dialect. |
| Prompts | src/server/mcp/prompts-data.ts |
Pre-baked instructional prompts consumed by clients. |
| Creatio API | src/creatio/services/* |
CreatioServiceContext composes auth + http client + providers; extend providers instead of bypassing them. |
| Client auth (HTTP) | src/server/bearer/*, src/server/oauth/* + http/broker-handlers.ts |
bearer/ = stateless edge (delegated: RFC 9728 metadata; gateway: trust injected token). oauth/ + broker-handlers = the broker mode: MCP is its own OAuth 2.1 AS (DCR + /authorize + /token) brokering authorization_code+PKCE to Creatio and holding user tokens server-side (SessionContext). |
- All date/time fields passed to Creatio MUST be UTC ISO8601 with
Zsuffix. - Activity creation: Always set
OwnerIdandAuthorIdto current user's ContactId obtained viaget-current-user-infounless user explicitly specifies another owner. - Avoid adding blocking network calls in tool descriptors—descriptors must be static; logic belongs in handlers.
- Never silently swallow errors coming from Creatio—log via
log.errorthen rethrow. - Keep tool names stable: lowercase kebab-case (e.g.
execute-process). - Auth selection: an explicit
CREATIO_MCP_AUTH_MODEalways wins; when unset the mode is INFERRED in order legacy (login+password) → client_credentials (id+secret) → delegated.broker/delegated/gatewayare HTTP-only. Token handling differs by mode:brokerissues its own client tokens AND stores users' Creatio tokens server-side (SessionContext);delegated/gatewaypass the client/gateway token straight through and store nothing;client_credentials/legacyhold one server-side identity. - Do not leak secrets or access tokens in tool responses.
CREATIO_MCP_READONLY=truemust guarantee no mutation tools (create,update,delete,execute-process,set-sys-settings-value,create-sys-setting,update-sys-setting-definition,refresh-feature-cache,upsert-admin-operation,delete-admin-operation,set-admin-operation-grantee,delete-admin-operation-grantee,call-configuration-service) are registered. This is enforced at two layers: the MCP layer does not register these tools, AND the engine layer's_mutatethrowsReadonlyModeError(defense-in-depth) — both readCREATIO_MCP_READONLY.
- Define input shape in
tools-data.tsusingzod. - Provide rich description (include examples, edge cases, warnings).
- Export descriptor & input schema.
- Add a row to the declarative tool table in
server.ts(_clientToolDefs()→corefor reads,mutatingfor writes — onlymutatingtools are gated out in readonly mode). Each row is{ name, descriptor, input, run }. - Implement
runby calling the appropriate engine on theCreatioEngineManager(crud,process, …); if functionality is missing, extend or add a provider undersrc/creatio/services(+ itscontracts/interface) rather than issuing raw fetch calls. New mutating engine methods must route throughBaseEngine._mutate. - Return raw domain data from
run— the single_normalizeToToolHandlerwraps it into{ content: [{ type: 'text', text }] }(objects/arrays areJSON.stringify-ed, strings passed through, a genuine{ content: [...] }envelope passed through as-is). Do not hand-wrap. - Add edge-case validation (empty arrays, invalid GUID, missing required filter fields).
- Write tests (see §10): a
server.test.tscase asserting the handler delegates + readonly gating, plus provider-level tests viamakeHttpClientHarnessfor any newsrc/creatio/servicescode. Runnpm run test:coverageand stay ≥90%. - Update documentation (README if public feature; otherwise just AGENTS.md).
Some capabilities only exist on certain Creatio environments (e.g. DataForge — the AI semantic layer over the data model — is present only when DataForgeServiceUrl is configured). Such tools must NOT be registered unconditionally. Use the ToolPreparer pattern instead of inlining probes into server.ts.
Contracts live in src/server/mcp/tool-preparer.ts:
ToolPreparer—{ name; prepare(registrar): Promise<boolean> }. Probes the environment and, only when available, registers its tools. Returns whether the capability is enabled.ToolRegistrar— thin sink (register(name, descriptor, handler)) that decouples preparers fromServerinternals.
How it wires up:
Serverbuilds the capability's client + preparer in its constructor and pushes the preparer into_preparers— UNLESS the capability is force-disabled viaServerConfig(disableDataForge/disableGlobalSearch, fed from envCREATIO_MCP_DISABLE_DATAFORGE/CREATIO_MCP_DISABLE_GLOBAL_SEARCH). A disabled capability is never added to_preparers, so it is neither probed (no network / no token spend) nor registered — even on an environment where it IS available._isDataForgeReady()then stays false, sodescribe-entityfalls back to the active CRUD backend.ensureCapabilitiesProbed(baseUrlOverride?)runs_prepareTools(state)once per tenant, lazily, from INSIDE the first request'srunWithContext— so the probe's Creatio calls carry the caller's identity (broker mode has no user otherwise) and the verdict is keyed to the caller's instance. It is non-blocking (fire-and-forget, so the MCP handshake isn't delayed) and self-healing: a preparer that returns cleanly records its verdict in the tenant'scapabilitiesmap and is never re-probed; one that THROWS (e.g. identity not usable yet) records nothing, so a later authenticated connect retries it. Newly-registered tools are pushed into that tenant's live session servers (the SDK emitstools/list_changed). See the per-tenant isolation invariant in §2.- Core tools can branch on a capability via
_capabilities(e.g.describe-entityroutes through DataForge when ready, otherwise falls back to OData — see below).
Capability clients share the narrow REST/sys-setting contracts and the
hasNonEmptySetting/getSettingValue helpers in src/server/mcp/creatio-rest.ts
(QuerySysSettings returns each setting as { code, value, … } — always unwrap .value).
Three capabilities follow this pattern today:
- DataForge (
dataforge/, 5 tools + describe-entity routing) — gated onDataForgeServiceUrl. - Global Search (
globalsearch/, oneglobal-searchtool) — gated onGlobalSearchUrl. - Published tools (
crtmcp/) — a hidden, opt-in proxy for theCrtMCPPublishingAppcomposable app. Gated on theCREATIO_MCP_ENABLE_PUBLISHED_TOOLSenv flag (default off) AND the app being installed. Enumerates onlineMcpServers, calls each server's JSON-RPC/0/rest/ToolServiceMcp/{code}/v1/mcptools/list, and re-exposes each published tool under apub-<server>-<tool>name that proxiestools/callback to the app (the app keeps ownership of schema/RBAC/validation/execution). JSON Schema → Zod viajson-schema-to-zod.ts; multi-segment route reached viaConfigurationCaller.rawPath. Intentionally undocumented in the README.
Add the next capability the same way.
Reference implementation (src/server/mcp/dataforge/):
DataForgeClient— single responsibility: talk to the Creatio-hosted DataForge REST services (DataForgeSchemaReadService,DataForgeMaintenanceService). Wraps single-parameter DTOs underrequest(WCFBodyStyle = Wrapped), depends on narrowConfigurationCaller/SysSettingReaderinterfaces (DIP), and exposesisEnabled()(probe viaDataForgeServiceUrl) plusgetColumnsOrNull()for graceful per-call fallback.DataForgeToolPreparer— registersdataforge-similar-tables,-table-details,-table-relationships,-lookup-values,-statusonly whenisEnabled()is true.
Rules for new gated capabilities:
- Add a client (talks to Creatio, no MCP knowledge) + a
ToolPreparer(registers tools). Do not put endpoint logic inserver.ts. - Probe must be cheap and degrade to "disabled" on any error (never throw out of
prepare). - Read-only gated tools are registered regardless of
CREATIO_MCP_READONLY(they do not mutate); keep mutating gated tools behind the readonly check. describe-entityenrichment: when DataForge is enabled it returns{ source: 'dataforge', entitySet, dataForge }, otherwise{ source: 'odata', entitySet, metadata }. Preserve thissourcediscriminator if you touch it.
- Use
try/catchin handlers ONLY if you need to wrap/transform the error. - Log with contextual tag:
log.error('mcp.tool.handler', err). - Throw the original error afterward (MCP layer will relay).
- For validation failures rely on
withValidation(...)wrapper.
- Use
log.info('mcp.tool.register', { tool })when registering tools. - Use
log.warnfor non-fatal recoverable issues (e.g., partial data fetch). - Use
log.errorstrictly for failures that abort the operation.
- Prefer
select+expandto limit payload size; educate users via descriptor text. - Batch calls carefully—avoid sequential redundant reads if data already provided.
- Avoid adding expensive synchronous CPU logic inside handlers.
- Never echo passwords or client secrets back to clients.
- Strip or mask token-like values if accidentally included in objects.
- Validate GUID format (8-4-4-4-12 hex) when exposing user input into queries.
There is a real test suite (Vitest + supertest) and every code change must ship with tests. This is not optional.
- No PR without tests. Any new tool, provider, handler, util, or bug fix must add or update tests in the same change.
- Coverage gate: ≥90% statements/functions/lines. Run
npm run test:coverageand do not regress below 90%. - Fixing a bug = writing a regression test first that fails on the old behavior, then making it pass. For security/perf fixes, label the test with the finding (e.g.
// C1,// H2) so the intent survives. npm testandnpm run buildmust both be green before committing.
npm test— run the whole suite once.npm run test:watch— watch mode while developing.npm run test:coverage— coverage report (v8).
test/
unit/ ← pure logic + classes with fakes (most tests live here)
api/ ← supertest against the real Express app (HTTP/MCP routes)
support/ ← shared test harness (USE THESE, do not reinvent)
http-client.ts → makeHttpClientHarness(responder), jsonResponse, textResponse, bodyOf
fake-context.ts → makeFakeContext(authType) — a full CreatioProviderContext of vi.fn() stubs
test-server.ts → createTestServer(), createAuthProviderMock(), resetSessionContext()
Tests live outside src/ so the tsc build stays clean. Name files *.test.ts. Keep the logger quiet (the vitest config already sets CREATIO_MCP_LOG_LEVEL=silent).
| You changed… | Test it like this |
|---|---|
| A pure function (filters, validators, pkce, env, key formatting) | Plain unit test, no mocks. |
A service provider (src/creatio/services/*) |
makeHttpClientHarness(responder) gives a real CreatioHttpClient + stubbed fetch. Assert the request URL/method/body (bodyOf(calls[0])) and the parsed result. Cover the non-2xx error path too. |
A tool handler / registration (server.ts) |
new Server(new CreatioEngineManager(makeFakeContext()), {...}), then invoke (server as any)._handlers.get('tool-name')(payload) and assert the provider stub was called. Also assert readonly-mode gating. |
| An HTTP / OAuth / MCP endpoint | createTestServer() → supertest(app). Call resetSessionContext() in beforeEach. Assert status codes, redirects, and that secrets/identity are handled correctly. |
| An auth provider | vi.stubGlobal('fetch', vi.fn(...)) for the token endpoint, wrap calls in runWithContext({ userKey }), seed/read SessionContext.instance. |
| Time- or concurrency-sensitive code (TTL, refresh, schedulers) | vi.useFakeTimers() + vi.advanceTimersByTimeAsync(...); for dedup, fire N concurrent calls with Promise.all and assert the underlying op ran once. |
- Reset shared singletons (
SessionContext.instance) withresetSessionContext()between tests; build freshRateLimiter/HttpServer/providers per test. - Prefer driving real code through the harness over asserting on mocks-of-mocks. Service-provider tests use a real
CreatioHttpClient; onlyfetchis stubbed. - Cover the unhappy paths explicitly (4xx/5xx, parse failure, expired, missing token/identity) — that is where the bugs are.
- Process entry points (
cli.ts,index.ts) are excluded from coverage; unit-test their pure helpers instead.
The fastest way to smoke a real Creatio after a server change. It drives the compiled build over
MCP (spawns dist/cli.js for stdio, starts dist/index.js for HTTP), waits for the capability
probe to settle, asserts the tool surface (base / DataForge / Global Search / published), runs a
read smoke, and — when crud:true — a full create → read-back → update → delete → verify-gone
lifecycle on a throwaway record. NOT part of npm test (needs real creds + network).
npm run build
cp scripts/live-regression.example.json scripts/live-regression.local.json # then fill in creds
node scripts/live-regression.mjs # run every target
node scripts/live-regression.mjs --only http-broker # one targetTargets are described in a JSON config (scripts/live-regression.local.json is gitignored —
keep real credentials there; *.example.json is the committed schema). One target per
kind×mode: stdio (legacy), and http with mode = legacy | client_credentials |
delegated | gateway | broker. delegated/gateway mint a Bearer via clientCredentials
(or take a literal bearer); gateway sets baseUrlOverride (and run two targets with different
overrides to prove per-tenant isolation in one process). broker does DCR + PKCE + a local
callback catcher and prints the authorize URL, then waits — open it and log in (the interactive
consent is the point of broker mode). Use expect ({dataforge, published, baseOnly}) to assert
the per-instance capability surface. Add NODE_TLS_REJECT_UNAUTHORIZED=0 to a target's env when the
instance has a weak dev cert (it must also be in the broker serverEnv, since the server itself
calls Creatio's token endpoint).
The unit suite covers the auth logic; this is the recipe for a live end-to-end smoke test of each
CREATIO_MCP_AUTH_MODE against a real instance. Common to all: build first (npm run build), then
node dist/index.js with the env below. The dev stand's TLS cert key is weak, so the Node process
needs NODE_TLS_REJECT_UNAUTHORIZED=0 (and even then Node's fetch rejects it with
EE certificate key too weak — fetch a token with curl -k instead, see delegated/gateway).
broker — drive with a real OAuth MCP client (e.g. Claude Code), because the OAuth + browser
consent flow is the point of the mode:
CREATIO_MCP_AUTH_MODE=broker CREATIO_BASE_URL=… CREATIO_CLIENT_ID=… \
CREATIO_CLIENT_SECRET=… # only for a confidential Creatio app
CREATIO_MCP_JWT_SECRET=… # ≥32 chars; required in prod; set it so client tokens survive a restart
NODE_TLS_REJECT_UNAUTHORIZED=0 node dist/index.jsPoint the client at http://localhost:3000/mcp; it discovers the AS (RFC 9728/8414), registers
(DCR), and opens the browser for Creatio login. Verify in logs: /oauth/callback → 302 (no
broker.creatio.exchange_failed) → session.connect → mcp.prepare … enabled:true. The same Creatio
app works public (PKCE only), confidential (+ client_secret), and with "Enforce PKCE" on/off — the
broker always sends S256 PKCE. After a restart the in-memory Creatio tokens are gone, so the client's
still-valid JWT gets 401 invalid_token → it must re-authorize (in Claude Code: Clear
authentication → reconnect).
client_credentials / legacy / delegated / gateway — drive with a direct handshake. These
have no per-user OAuth, so a plain MCP client can't trigger a login; test them with a raw
Streamable-HTTP handshake instead: POST /mcp initialize → notifications/initialized →
tools/call get-current-user-info (carry the mcp-session-id header the initialize response
returns). Expect the call to resolve to the expected user.
client_credentials: envCREATIO_MCP_AUTH_MODE=client_credentials+CREATIO_CLIENT_ID/SECRET;/mcpis open (no edge), the provider injects the M2M token. Confirmcreatio.auth.ok authKind=oauth2.legacy: env…=legacy+CREATIO_LOGIN/CREATIO_PASSWORD. Confirmcreatio.auth.ok authKind=legacy.delegated/gateway: the request must carry a Creatio credential — eitherAuthorization: Bearer <a real Creatio token>(mint one out-of-band, e.g.curl -kthe client_credentials grant at<base>/0/connect/token) or a forwarded Forms-auth session inX-Creatio-Cookie(POST<base>/ServiceModel/AuthService.svc/Login, then forward the Set-Cookie value; BPMCSRF is read from it or from an explicitX-Creatio-Bpmcsrf). A request with no credential must get401;gatewayalso honorsX-Creatio-Base-Url. Dynamic tools (DataForge / Global Search /pub-*) are probed inside the request context, so they discover on the forwarded credential too.
src/version.ts reads the version dynamically from package.json. Use semantic commit
prefixes (feat:, fix:, docs:, chore:). Whenever you bump the version, you MUST do
the full release — never bump without tagging AND publishing:
npm version <x.y.z> --no-git-tag-version(updatespackage.json+ lockfile, no auto-tag).- Update
CHANGELOG.md(new version section; what changed, grouped Added/Fixed/Changed). First enumerate EVERY commit since the previous release tag —git log v<prev>..HEAD --no-merges --oneline— and make sure each user-facing change is reflected; do NOT changelog only your own session's commits (commits that landed since the last tag but before your work still ship in this release). Also add the version's reference-link definition at the bottom ([x.y.z]: https://github.com/CRACKISH/mcp-creatio/releases/tag/vx.y.z) so the heading links like the others. npm run build+npm test— must be green (the build also runs onprepack).- Commit:
chore(release): vX.Y.Z. - Tag:
git tag vX.Y.Z(use thevprefix). - Push commit + tag:
git push origin main && git push origin vX.Y.Z. - Publish to npm:
npm publish(public;prepackrebuildsdist; verifynpm view mcp-creatio version).
Skipping the tag or the npm publish leaves the release half-done — always finish all 7 steps.
Automated on the tag push (no manual step): .github/workflows/release.yml extracts the
CHANGELOG section and creates the GitHub Release; docker-publish.yml builds the multi-arch
image (:latest + :vX.Y.Z) and syncs the README to the Docker Hub overview. (Note: gh in
this dev env is authed to the GHE host, not github.com — creating github.com releases must go
through CI, not local gh. Backfill an old tag via the workflow's workflow_dispatch.)
| Case | Mitigation |
|---|---|
Empty select array supplied |
Preprocess to undefined (already handled) |
Mixed raw filter + structured filters |
Combine with and parenthesized in handler |
| Invalid GUID quoting | Provide guidance text + possible pre-validation before sending |
| Large result sets | Encourage top param (<200 recommended) |
| Readonly mode attempt to mutate | Ensure mutation tools not registered |
If adding new auth provider:
- Create provider under
src/creatio/auth/providers/(extendBaseProvider, which requires only the singleICreatioAuthProvidercontract:getHeaders+refresh+cancelAllRefresh). The current providers areLegacyProvider,OAuth2Provider(client credentials),OAuth2BearerProvider(stateless per-request passthrough — delegated/gateway; Bearer or forwarded cookie session viaInjectedCredential), andBrokerProvider(serves the user's broker-held Creatio tokens, refreshing on demand). - Add selection logic in
auth-manager.tsandconfig-builder.ts, preserving the order: explicitCREATIO_MCP_AUTH_MODEwins, else inferred legacy → client_credentials → delegated. - Document environment variables clearly in README + AGENTS.md.
Token model by mode. In
brokerthe MCP IS its own OAuth 2.1 authorization server for clients (src/server/oauth/+http/broker-handlers.ts): it does DCR +/authorize+/token(authorization_codeand rotatingrefresh_tokengrants), brokers the login to Creatio via authorization_code+PKCE, and holds each user's Creatio tokens server-side via aTokenStore(src/sessions/token-store.ts). The tokens it issues are audience-bound (aud=/mcp,iss=origin; verified on every/mcpcall) so a token from one deployment is rejected by another sharing the secret; the signing secret (CREATIO_MCP_JWT_SECRET) must be ≥32 chars and is required in production.Broker token store + prod. Default
InMemoryTokenStore(lost on restart — a401 invalid_tokenthen makes the client re-authorize; single instance only). For production setCREATIO_MCP_TOKEN_STORE=redis+CREATIO_MCP_REDIS_URL:RedisTokenStore(lazyredisdep) encrypts tokens at rest (AES-256-GCM,token-crypto.ts; key fromCREATIO_MCP_TOKEN_ENC_KEYelse derived from the JWT secret) with native per-key TTL → stateless, restart-durable, multi-instance. Behind a TLS-terminating proxy setCREATIO_MCP_PUBLIC_URLsoiss/aud/redirects/discovery use the external origin (resolvePublicOrigin), not the internal hop. Logout = RFC 7009POST /revoke(revocation_endpoint): revokes the Creatio token upstream (/connect/revocation, best-effort) + purges the server-side + issued-refresh tokens; always answers 200.In
delegated/gatewaythe MCP stores nothing and does not cryptographically verify the credential — both are fully-trusted-environment modes (Creatio is the authority; the request'suserKeyis an unverified, logging-only identity). The client (delegated) or a fronting Control-Plane (gateway) supplies a Creatio credential — either a Bearer token (obtained from Creatio Identity; delegated advertises it via RFC 9728) or a forwarded Forms-auth session (X-Creatio-Cookie+ BPMCSRF, the common shape for tenants without OAuth). The edge insrc/server/bearer/parses it into a typedInjectedCredential(bearer | cookie) and the passthrough provider attaches the matching headers statelessly — no cookie jar, no per-credential pool. Gateway'sX-Creatio-Base-Urloverride is validated againstCREATIO_MCP_ALLOWED_BASE_URLS(and always blocks cloud-metadata IPs) since it controls where the credential is sent. For an untrusted direct external client, usebroker.
Session keep-alive (single-session modes only).
legacy/client_credentialshold one shared Creatio session; a long idle period lets Creatio drop the forms cookie. Reactive reconnect (the HTTP client retries on401AND on a login-page→HTML bounce) keeps it correct;SessionKeepAlive(src/server/keepalive.ts) additionally pingsget-current-user-infoon an interval to avoid the first-call re-login latency.CREATIO_MCP_KEEPALIVE_SECONDScontrols it — default 300s (5 min),0disables. NOT used for broker/delegated/gateway (per-user / per-request, no shared session).
- Add new prompt object in
prompts-data.ts(name,title,description,argsSchema,callback). - Keep names unique and descriptive.
- Avoid external network calls inside prompt callbacks.
Write every change to the highest engineering bar — this codebase is held to SOLID, GRASP, Clean Code and proven design patterns, not "make it work". Before adding code, prefer the design that an experienced reviewer would: clear responsibilities, small seams, no leaks.
Principles (apply, don't just cite):
- SOLID — SRP (one reason to change per class: see the provider/translator/engine split);
OCP (extend via a new Strategy + factory branch, e.g. CRUD backends, not
if/elseedits); LSP (everyCrudProvider/auth provider is fully substitutable — no throwing stubs); ISP (keep contracts minimal, e.g. the single-capabilityICreatioAuthProvider,CrudCapabilities); DIP (handlers depend on contract interfaces, never concrete transports). - GRASP — Information Expert (the dialect lives in its translator; the backend owns its
capabilities), Pure Fabrication (translators/transport/query-builder), Low Coupling / High
Cohesion, Protected Variations (the neutral
ReadQuery/FilterNodeseam shields callers from backend differences). - Clean Code — intention-revealing names, small single-responsibility functions, no
duplication (extract shared helpers like
assertEntityName/lookupIdPath), comments explain why (platform quirks, wire-value provenance), guard clauses over deep nesting. - Patterns — Strategy (CRUD backends, auth, tool preparers), Factory (
createCrudProvider), Adapter (translators OData/DataService), Template Method (BaseEngine._mutate), Facade (CreatioServiceContext). Reach for the pattern that removes the smell — don't over-engineer.
Baseline rules (full list in docs/coding-style.md):
- Keep TypeScript strict (avoid
any, prefer explicit interfaces). - Class-member ordering: readonly fields → fields → getters → setters → constructor → methods, each
private → protected → public. - Prefix private fields/methods with
_; reuse utility wrappers (withValidation,client.request); reuse established logging tags. - Keep functions small and single-responsibility; isolate dialect/transport specifics behind a seam.
Tests are part of "done" (see §10): every code change ships with tests in the same change — unit for pure logic (translators, value-type, filters) and full-stack/API for wiring (Server→engine→provider). New behavior or a fixed bug without a covering test is incomplete.
- Do not reintroduce removed tools (
search,fetch) unless strong justification & design review. - Do not bypass
CreatioServiceContext/providers with raw fetch calls scattered in handlers. - Do not embed long multi-megabyte payloads in commit history (limit sample data).
- Make code edits and their tests together (§10).
- Run
npm test— the suite must be green. - Run
npm run buildto ensure TypeScript passes. - Run
npm run test:coverageand confirm ≥90% (no regression). - Optionally lint:
npm run lint. - Commit using a conventional message.
- Push; consider tag if version bump.
- Implement persistent token storage (e.g., file/Redis) for broker-held Creatio tokens (today in-memory in
SessionContext, lost on restart). - Provide structured error codes instead of raw messages.
- Raise branch coverage toward 90% and wire
npm testinto CI. - Live-regress the DataService backend against a real environment, then consider dropping the
raw OData
$filter/expandescape hatches entirely (structuredfiltersis portable). - Split
CrudProviderper ISP intoICrudProvider+ISchemaProvider(schema discovery is backend-independent); today the DataService schema lives inDataServiceSchemaProvider.
If you need business logic clarification, check existing descriptor guidance first. Many patterns (date handling, ownership) are documented inline in tools-data.ts.
Summary: Focus on safe MCP tool extension around Creatio CRM. Preserve invariants, keep descriptors rich, centralize API interactions, and avoid leaking credentials.