mattermost-boards-mcp
v1.0.0
Published
MCP server for Mattermost Boards. Lets an agent create, read, change and watch boards and cards — and mention people in them — with the same permissions as the account whose token it carries.
Maintainers
Readme
mattermost-boards-mcp
Mattermost Boards, as tools an agent can actually use. Boards, cards, properties, comments, members, mentions and change-watching — over MCP, with the permissions of whichever account's token you hand it.
agent ──MCP──▶ mattermost-boards-mcp ──HTTPS──▶ Mattermost + Boards plugin
(stdio or http) (your token, your rights)It adds no permission model of its own. Mattermost already knows who may read a board and who may change it — the token is the scope.
Sixty seconds
npx mattermost-boards-mcp --url https://team.example.com --token "$MATTERMOST_TOKEN" --team mainPoint any MCP client at it:
{
"mcpServers": {
"boards": {
"command": "npx",
"args": ["-y", "mattermost-boards-mcp"],
"env": {
"MATTERMOST_URL": "https://team.example.com",
"MATTERMOST_TOKEN": "…",
"MATTERMOST_TEAM": "main"
}
}
}
}The token is a personal access token or a bot token. It needs nothing special — no admin, no system console, no separate integration.
What you get
| | |
| --- | --- |
| Boards that open | create, list, search, change, duplicate, delete — and repair |
| Cards by name | properties as {"Status": "Done"}, never as opaque ids |
| Bodies and comments | markdown in, markdown out |
| Mentions that arrive | and a warning when one would not |
| Members | who may do what, per board |
| Watching | subscribe a channel, or poll cheaply |
| Scope | read-only, chosen toolsets, chosen teams and boards |
| Two ways to host it | sidecar per agent, or one shared server |
Boards that open
A minimal POST /boards against the plugin succeeds, stores cardProperties:
null, and produces a board the web client cannot render — "Sorry, something went
wrong", while every API call against it still returns 200. board_create always
sends properties and a view, so this never happens.
board_create { "title": "Roadmap" }
// → open board, a Status select (To do / In progress / Done), an Assignee person,
// and a Kanban view grouped by StatusAlready have a broken one, from some other script?
board_repair { "boardId": "b…" } // one PATCH; the cards on it surviveCards by name
Boards stores a card's fields as {propertyId: optionId}. You never see either.
card_create {
"boardId": "b…",
"title": "Ship the release",
"properties": { "Status": "In progress", "Assignee": "andrey" },
"body": "Blocked on the CI runner.\n\nRetry after the upgrade."
}Get a name wrong and the error tells you what exists:
property "Staus" is not on this board. It has: Status, Assignee
property "Status" has no option "Blocked". It has: To do, In progress, Done.
Pass createOptions: true to add it.Filter without reading every card:
cards_list { "boardId": "b…", "where": { "Status": "In progress" } }Bodies and comments
Markdown goes in, splits into content blocks on blank lines (fenced code stays whole), and comes back as the same markdown.
card_comment { "cardId": "c…", "text": "Runner is back, retrying." }
card_comments { "cardId": "c…" } // oldest first, with who and whenReplacing a card's body does not touch its comments.
Mentions that arrive
A Boards mention is a side effect of storing a block whose title contains
@name; the Boards bot then DMs that person a link to the card. On a private
board a mention of a non-member is dropped — logged at debug, success returned,
nobody told.
card_mention { "cardId": "c…", "users": ["andrey"], "message": "can you review this" }
// → { "notified": ["andrey"], "addedToBoard": ["andrey"], "text": "@andrey can you review this" }With addToBoard: false on a private board you get the truth instead of silence:
// → { "notified": [], "warning": "andrey will NOT be notified: this is a private
// board and they are not members. Call again with addToBoard: true, …" }Members
board_members { "boardId": "b…" }
board_member_add { "boardId": "b…", "user": "andrey", "role": "editor" }
board_join { "boardId": "b…" } // open boards only, and it says so if notRoles are viewer, commenter, editor, admin — the four flags the plugin
stores, expressed as one name.
Watching
Two ways, for two situations.
Subscribe a channel. A Boards subscriber may be a user or a channel. Point a board at the channel your agent already listens in, and changes arrive through the gateway that is already running — no polling, no turn spent waiting.
watch_add { "boardId": "b…", "channel": "project-alpha" }Poll cheaply. One call tells you which boards moved; only then read them.
board_activity { "since": "2026-08-13T09:00:00Z" } // → the boards that changed
cards_changed_since { "boardId": "b…", "since": "2026-08-13T09:00:00Z" }Boards' own metadata endpoint needs a Mattermost licence and answers 501
appropriate license required without one. board_activity falls back to the
newest card on each board and tells you it did — everything else here works
unlicensed.
There is no blocking wait tool, on purpose: a tool that sleeps holds an agent's whole turn open doing nothing.
Scope
Everything below only ever narrows what the token already permits.
scope:
readOnly: true # every writing tool disappears, and is refused if called
toolsets: [directory, cards] # of: directory, boards, cards, members, monitor
teams: [main] # acting elsewhere is refused
boards: [b1234…] # only these boards
denyTools: [board_delete]mattermost-boards-mcp --read-only --toolsets directory,boards,cards --teams mainTwo ways to host it
Sidecar (default). One server per agent, in the agent's own container, reusing
the MATTERMOST_TOKEN that is already there. The agent's reach is exactly that bot
account's reach, and there is nothing new to provision.
Shared. One server for several agents and people:
mattermost-boards-mcp --http --host 0.0.0.0 --port 9100Every request must carry its own Authorization: Bearer <mattermost-token>, and a
token in the config is refused at startup rather than used as a fallback —
otherwise one stored credential would flatten every caller into one account. It is
stateless: no session id, no server-side store, GET refused because there is no
stream to open, and Origin checked.
Configuration
Flags, environment, or a YAML/JSON file — later wins. ${VAR} expands inside the
file, so a tracked config holds no secret.
mattermost:
url: https://team.example.com
token: ${MATTERMOST_TOKEN}
team: main
server:
transport: stdio # or http
host: 127.0.0.1
port: 9100
path: /mcp
scope:
readOnly: false
toolsets: [directory, boards, cards, members, monitor]
log:
level: warn # error, warn, info, debug — always on stderrmattermost-boards-mcp --config boards.yaml --check # validate and exit
mattermost-boards-mcp --helpEnvironment: MATTERMOST_URL, MATTERMOST_TOKEN, MATTERMOST_TEAM,
BOARDS_MCP_CONFIG, BOARDS_MCP_TRANSPORT, BOARDS_MCP_HOST, BOARDS_MCP_PORT,
BOARDS_MCP_PATH, BOARDS_MCP_READ_ONLY, BOARDS_MCP_TOOLSETS,
BOARDS_MCP_TEAMS, BOARDS_MCP_BOARDS, BOARDS_MCP_DENY_TOOLS,
BOARDS_MCP_LOG_LEVEL.
Requirements
Node 22+. Mattermost with the Boards plugin (focalboard) installed and enabled —
it is no longer bundled; it lives at
mattermost/mattermost-plugin-boards.
Pick a release by the min_server_version inside its bundle, not by version
number: two release lines ship in parallel and the tags do not order by date.
Developed against plugin 9.3.1 on Mattermost 11.10.0.
Develop
npm test # node --test; no Mattermost needed, and none is contactedThe suite runs against test/fixtures/fake-mattermost.js — a real HTTP server
that enforces the plugin's actual rules, including the CSRF header, the "O"/"P"
board types and the non-zero createAt. A stubbed fetch would agree with
whatever the client did and prove nothing.
Durable knowledge about this codebase lives in .hint files next to the code and
is queried with HINT — hint src/tools/cards.js prints
what applies before you change it.
Licence
Apache-2.0.
