From a121c97cdeb6df46dca30579122d3e68eff789c38f2437a9443a53440a54f553 Mon Sep 17 00:00:00 2001 From: mkt Date: Tue, 16 Jun 2026 16:33:37 +0200 Subject: [PATCH] Add Tags-filter and phase2 ping-MCP instruction docs Co-Authored-By: Claude Opus 4.8 (1M context) --- espocrm-tags-filterfeld-claude-code.md | 97 +++++++++++++++ phase2-ping-mcp-claude-code.md | 165 +++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 espocrm-tags-filterfeld-claude-code.md create mode 100644 phase2-ping-mcp-claude-code.md diff --git a/espocrm-tags-filterfeld-claude-code.md b/espocrm-tags-filterfeld-claude-code.md new file mode 100644 index 0000000..3405ebc --- /dev/null +++ b/espocrm-tags-filterfeld-claude-code.md @@ -0,0 +1,97 @@ +# EspoCRM 9.3.8 — Make the custom "Tags" relation filterable on all four entities + +Self-contained instruction for Claude Code on the EspoCRM host (sv006). It assumes nothing about any other repository or plan. + +## Goal + +This EspoCRM instance has a custom many-to-many "Tags" relation to a custom tag entity **`CTag`**. It currently exists on **Opportunity**, **Contact**, and **Account** (as a relationship / bottom panel only — *not* as a field), and **does not exist at all on `Lead`** yet. + +Two things to achieve: + +1. **On Opportunity, Contact, Account:** expose the existing relation as a **`linkMultiple` field** named `cTags`, so it can be added to the "Search Filters" layout (the list-view filter dropdown, the "three dots", only offers *fields*). +2. **On Lead (Interessent):** build the **full** Tags setup from scratch in parity with the other three — create the relation, then add the field, label, bottom panel, the same panel polish, and the search filter. + +End state: on all four entities you can tag records and filter the list by tags (Any of / All of / None of). + +**Constraints:** For the existing three this must **not** create a new relationship or change data — only add a field bound (by name) to the relation that already exists. linkMultiple fields are `notCreatable` in the Field Manager, so the field is added via custom metadata + rebuild. For Lead, the relation *is* created (a new join table is expected). Mirror the existing three exactly. + +## Verified facts (from live `/api/v1/Metadata`) + +The relation is a symmetric custom many-to-many (each side `type: hasMany` with `relationName`, `isCustom: true`): + +| Scope | owner link | foreign entity | foreign (reverse) link | relationName | status | +|---|---|---|---|---|---| +| Opportunity | `cTags` | `CTag` | `verkaufschancen` | `cOpportunityTag` | relation exists; field missing | +| Contact | `cTags` | `CTag` | `kontakte` | `cContactTag` | relation exists; field missing | +| Account | `cTags` | `CTag` | `firmen` | `cAccountTag` | relation exists; field missing | +| **Lead** | `cTags` | `CTag` | `interessenten` | `cLeadTag` | **relation does NOT exist — must be created** | + +Reference configs from the existing three (to mirror for Lead): +- A known-good linkMultiple field: `Opportunity.fields.teams = { "type": "linkMultiple", "view": "views/fields/teams" }`. +- `clientDefs..relationshipPanels.cTags = { "create": false, "rowActionsView": "custom:views/record/row-actions/tags-unlink-only" }` (identical on Opportunity/Contact/Account). The referenced row-actions view already exists in this instance. + +Also note: the custom link field **`cAccount1`** ("Über Agentur") was added to **Opportunity's** Search-Filters layout earlier today via the layout API — preserve it. + +## Part A — Opportunity, Contact, Account (relation already exists) + +For each of the three scopes: + +**A1. Field def** — merge into `custom/Espo/Custom/Resources/metadata/entityDefs/.json`, key `fields`: +```json +"cTags": { "type": "linkMultiple", "view": "views/fields/link-multiple", "isCustom": true } +``` +Binds to the existing `cTags` link by name. Do **not** add `relationName`/`midKeys`/`entity` to the field. + +**A2. Label** — merge `fields.cTags = "Tags"` into `custom/Espo/Custom/Resources/i18n/de_DE/.json` and `.../en_US/.json`: +```json +{ "fields": { "cTags": "Tags" } } +``` + +**A3. Search-Filters layout** — `custom/Espo/Custom/Resources/layouts//filters.json` is a flat JSON array of field names. **Read the current file and append `cTags`** (e.g. after `teams`); do not remove anything. +- For **Opportunity** specifically: ensure the array also still contains `cAccount1` (added today); if absent, insert it right after `account`. + +## Part B — Lead / Interessent (build full parity) + +`Lead` is enabled (`scopes.Lead.disabled = false`, customizable). It has no Tags relation today. Replicate the exact setup the other three have. + +**B1. Create the relationship.** Create a many-to-many relation between `Lead` and `CTag` using the same mechanism the existing tag relations were created with (Entity Manager / relationship creation), so the join table and mid keys are generated correctly — do not hand-roll if the proper tool is available. Target names (mirror the pattern): +- Lead-side link: `cTags` (entity `CTag`) +- CTag-side reverse link: `interessenten` (entity `Lead`) +- relationName: `cLeadTag` +- A new join table (e.g. `c_lead_tag`) is expected to be created on rebuild. + +**B2. Field** — add the same field def as A1 to `Lead` entityDefs. + +**B3. Labels** — mirror the others: +- `Lead` i18n (de_DE + en_US): `fields.cTags = "Tags"` and `links.cTags = "Tags"`. +- `CTag` i18n (de_DE + en_US): `links.interessenten = "Interessenten"` (en: "Leads"). + +**B4. Panel polish** — set in `custom/Espo/Custom/Resources/metadata/clientDefs/Lead.json`: +```json +"relationshipPanels": { "cTags": { "create": false, "rowActionsView": "custom:views/record/row-actions/tags-unlink-only" } } +``` +(identical to the other three; the view already exists). + +**B5. Tags bottom panel visible on Lead detail** — ensure the Tags panel appears on the Lead detail view exactly as on Opportunity/Contact/Account. Inspect how the panel is enabled on those three and reproduce it for Lead; verify visually in the UI. + +**B6. Search-Filters layout** — append `cTags` to `custom/Espo/Custom/Resources/layouts/Lead/filters.json` (read + append). + +## Rebuild + +Run EspoCRM's rebuild so the new field(s) and (for Lead) the relation become active (e.g. `php command.php rebuild` inside the app container). For A this is metadata/ORM-cache only (relation tables already exist); for B the rebuild creates the new `c_lead_tag` join table. + +## Verify + +- `GET /api/v1/Metadata` (admin): `entityDefs..fields.cTags` exists with `type: "linkMultiple"` for **all four** scopes; `entityDefs.Lead.links.cTags` and `entityDefs.CTag.links.interessenten` exist. +- UI, each of the four lists: filter ("three dots") → **"Tags"** appears; choosing it offers **Any of / All of / None of** with a tag autocomplete; filtering returns correct records. +- The **Tags bottom panel** works on all four detail views (incl. Lead); the "+"/inline-create is gone and the row menu only offers "remove link" (parity). +- No data changed on the existing three; **`cAccount1` ("Über Agentur")** still present in the Opportunity filter list. + +## After applying + +GUI/metadata customization → run **`snapshot_espocrm_custom.sh`** (+ commit in the `bin` repo) so the next `create_pod_espocrm.sh` run does not revert it. This single snapshot also captures today's earlier `cAccount1` filter-layout change. + +## Notes / limits (context, no action) + +- linkMultiple filter operators: `anyOf` (linkedWith), `allOf` (linkedWithAll = AND), `noneOf` (notLinkedWith), `isEmpty`, `isNotEmpty`. +- *Include one tag AND exclude another in the same view* (e.g. "has `Sommerkampagne` but not `15 % Preisnachlass`") is **not** possible with the standard field filter (one filter per field, one operator). That needs a separate named preset filter and is out of scope here. diff --git a/phase2-ping-mcp-claude-code.md b/phase2-ping-mcp-claude-code.md new file mode 100644 index 0000000..ed1cd69 --- /dev/null +++ b/phase2-ping-mcp-claude-code.md @@ -0,0 +1,165 @@ +# Claude Code task — minimal "ping" MCP server on sv006 (reachability test) + +**Audience:** Claude Code, run by user `mkt` on host **sv006**. +**Goal:** Stand up a tiny, self-contained Model Context Protocol (MCP) server that exposes a single `ping` tool over the **Streamable HTTP** transport. It is a throwaway reachability probe — it does **not** talk to EspoCRM and needs no secrets. A separate reverse-proxy route will publish it as `https://mcp-crm.creature-go.com/mcp`; you only build the local container. + +This is intentionally a stand-in. The port and container layout are chosen so the public hostname and proxy route can be reused **unchanged** for the real CRM-MCP server later. + +## Fixed parameters (do not change — other components depend on them) + +| Parameter | Value | +|---|---| +| Host loopback bind | `127.0.0.1:8095` (reserved "MCP port") | +| Container internal port | `8095` | +| MCP endpoint path | `/mcp` (Streamable HTTP, POST) | +| Health path | `/health` (GET, returns `ok`) | +| Container name | `ping_mcp_ctr` | +| Image tag | `ping-mcp:1.0.0` | +| Runtime | rootless Podman, user `mkt` | + +The container must publish **only on host loopback** (`127.0.0.1:8095`). The rootless Traefik pod reaches it via the slirp4netns host-loopback address `10.0.2.2:8095` (same mechanism the EspoCRM pod already uses) — do not publish on `0.0.0.0`. + +## Step 1 — Create the app directory `~/ping_mcp/` + +**`~/ping_mcp/package.json`** +```json +{ + "name": "ping-mcp", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "express": "^4.21.0" + } +} +``` +Use the current published versions if these are outdated; the only requirement is a Streamable-HTTP-capable `@modelcontextprotocol/sdk`. + +**`~/ping_mcp/server.js`** +```js +import express from "express"; +import { randomUUID } from "node:crypto"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; + +const PORT = 8095; +const app = express(); +app.use(express.json()); + +// Plain HTTP health check (NOT part of MCP) — for humans / curl. +app.get("/health", (_req, res) => res.status(200).send("ok")); + +function buildServer() { + const server = new McpServer({ name: "ping-mcp", version: "1.0.0" }); + server.tool( + "ping", + "Reachability health-check. Returns pong with a timestamp and a server identity string.", + {}, + async () => ({ + content: [{ + type: "text", + text: `pong @ ${new Date().toISOString()} — ping-mcp on sv006 (nonce ${randomUUID()})`, + }], + }), + ); + return server; +} + +// Stateless Streamable HTTP: a fresh server + transport per request. +app.post("/mcp", async (req, res) => { + try { + const server = buildServer(); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on("close", () => { transport.close(); server.close(); }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (err) { + console.error("MCP request error:", err); + if (!res.headersSent) { + res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null }); + } + } +}); + +// Stateless mode has no server-initiated stream / session teardown. +const notAllowed = (_req, res) => + res.status(405).json({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed (stateless)" }, id: null }); +app.get("/mcp", notAllowed); +app.delete("/mcp", notAllowed); + +app.listen(PORT, "0.0.0.0", () => console.log(`ping-mcp listening on 0.0.0.0:${PORT} (/mcp, /health)`)); +``` + +> If the installed SDK's API differs (e.g. `registerTool` instead of `tool`, or a changed `StreamableHTTPServerTransport` constructor), adapt the code. The contract that matters: a working **stateless Streamable HTTP** MCP endpoint at `POST /mcp` exposing exactly one tool named `ping`, plus a `GET /health` returning `ok`. Binding `0.0.0.0` inside the container is correct; the container is only published to host loopback. + +**`~/ping_mcp/Containerfile`** +```dockerfile +FROM docker.io/library/node:22-bookworm-slim +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev +COPY server.js ./ +EXPOSE 8095 +USER node +CMD ["node", "server.js"] +``` +Pin the base image to a concrete patch tag (e.g. `node:22.x.y-bookworm-slim`) consistent with the house pinning discipline used for the EspoCRM/MariaDB images. + +## Step 2 — Build and run (rootless Podman, user `mkt`) + +```bash +cd ~/ping_mcp +podman build -t ping-mcp:1.0.0 . +podman run -d --name ping_mcp_ctr -p 127.0.0.1:8095:8095 ping-mcp:1.0.0 +``` + +## Step 3 — Autostart via user systemd + linger + +```bash +mkdir -p ~/.config/systemd/user +cd ~/.config/systemd/user +podman generate systemd --new --name ping_mcp_ctr --files +systemctl --user daemon-reload +systemctl --user enable --now container-ping_mcp_ctr.service +loginctl enable-linger mkt # likely already enabled for the EspoCRM pod; harmless if so +``` + +## Step 4 — Local verification (before handing off) + +```bash +# 1) Health endpoint +curl -s http://127.0.0.1:8095/health # expect: ok + +# 2) MCP initialize handshake (Streamable HTTP requires this Accept header) +curl -s -X POST http://127.0.0.1:8095/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' +# expect: a JSON-RPC result containing serverInfo {name:"ping-mcp", ...} and capabilities. + +# 3) tools/list +curl -s -X POST http://127.0.0.1:8095/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' +# expect: a tools array containing one tool named "ping". +``` +The response body may be framed as `text/event-stream` (a `data:` line) rather than plain JSON — that is expected for Streamable HTTP. As long as the JSON-RPC result is present and well-formed, the server is good. + +## Definition of done + +- `curl http://127.0.0.1:8095/health` returns `ok`. +- The `initialize` and `tools/list` calls return valid JSON-RPC results, and `tools/list` shows the `ping` tool. +- `systemctl --user status container-ping_mcp_ctr.service` is active and enabled. +- Report back: confirmation of the above, plus the exact base-image tag you pinned. + +## Teardown (after the reachability test concludes) + +```bash +systemctl --user disable --now container-ping_mcp_ctr.service +rm -f ~/.config/systemd/user/container-ping_mcp_ctr.service && systemctl --user daemon-reload +podman rm -f ping_mcp_ctr +podman rmi ping-mcp:1.0.0 +# ~/ping_mcp/ can be kept as a template or removed. +```