Defines how to add Operately MCP tools (API-first wrappers). Use when creating, changing, or reviewing MCP tools under app/lib/operately_web/mcp/tools/, when the user mentions MCP tools, tool catalog, @expected_tool_names, or when exposing a new Operately capability to ChatGPT/Claude MCP clients.
Scanned 8/30/2026
Install to Claude Code
npx -y skills add operately/operately --skill mcp-tools --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Mcp Tools?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/operately-mcp-tools)More formats (shields.io, HTML) on the badges page.
---
name: mcp-tools
description: >-
Defines how to add Operately MCP tools (API-first wrappers). Use when creating,
changing, or reviewing MCP tools under app/lib/operately_web/mcp/tools/, when
the user mentions MCP tools, tool catalog, @expected_tool_names, or when
exposing a new Operately capability to ChatGPT/Claude MCP clients.
---
# MCP Tools
MCP tools are thin wrappers over `OperatelyWeb.Api.*` handlers. API endpoints
are always the source of truth for auth, permissions, business logic, and
serialization. MCP tools only declare MCP-facing metadata (schemas, scopes,
examples) and adapt string-key JSON arguments into API inputs.
For templates, test skeletons, sort_order bands, and a full walkthrough, see
[reference.md](reference.md).
## Architecture
```mermaid
flowchart TD
start[User wants capability X] --> checkTool{MCP tool exists?}
checkTool -->|yes| stopDone[Stop or extend existing tool]
checkTool -->|no| checkApi{OperatelyWeb.Api handler exists?}
checkApi -->|yes| wrapOnly[Write MCP wrapper + MCP tests]
checkApi -->|no| addApi[Add API handler + register in api.ex]
addApi --> apiTests[Add API tests with TurboCase]
apiTests --> wrapOnly
wrapOnly --> catalog[Update tools_test.exs @expected_tool_names]
catalog --> done[Run targeted tests]
```
| Layer | Location | Responsibility |
| ----- | -------- | -------------- |
| API (source of truth) | `app/lib/operately_web/api/` | Auth, permissions, business logic, serialization |
| API wrappers (when needed) | `app/lib/operately_web/api/wrappers/` | Adapt inputs MCP exposes differently (e.g. hub scope → `resource_hub_id`) |
| MCP tool | `app/lib/operately_web/mcp/tools/` | JSON schema, MCP metadata, string-key arg decoding, call API |
| Catalog | `Registry` (auto) + `tools_test.exs` (manual) | Discovery and invariant tests |
Product rationale: `specs/0012-operately-mcp.md` § "Shared execution across surfaces".
## OAuth scopes
MCP grants use two scopes. There is no `mcp:destructive` scope — destructive tools stay on
`mcp:write`, matching the CLI write-token model.
| Scope | `safety_classification` | Tool access |
| ----- | ----------------------- | ----------- |
| `mcp:read` | `:read_only` | Read-only tools |
| `mcp:write` | `:write` or `:destructive` | All write and destructive tools |
### Default scope on connect
`Operately.Mcp.Resources.parse_scopes/1` defaults **nil, empty string, or empty list** to
`mcp:read` only. Clients that need write or destructive access must request `mcp:write`
explicitly in the OAuth `scope` parameter.
When authoring tools:
- Read-only → `required_scopes: ["mcp:read"]`, `safety_classification: :read_only`,
`read_annotations()` (`readOnlyHint: true`)
- Write → `required_scopes: ["mcp:write"]`, `safety_classification: :write`,
`write_annotations()`
- Destructive → `required_scopes: ["mcp:write"]`, `safety_classification: :destructive`,
`write_annotations()` plus `destructiveHint: true` in descriptors (client UX only —
not a third scope)
Runtime enforcement in `OperatelyWeb.McpController` checks token scopes against each tool's
`required_scopes` before dispatch.
## Scope
This skill covers adding, changing, and reviewing MCP tool wrappers.
Removing a tool requires deleting the module, its test, and its entry in
`@expected_tool_names`.
## Workflow
Always follow these three steps before writing code.
### 1. Check if the MCP tool already exists
- Grep `app/lib/operately_web/mcp/tools/` by domain and action name
- Read `@expected_tool_names` in `app/test/operately/mcp/tools_test.exs` — the
canonical sorted catalog
- Run the catalog test or inspect `OperatelyWeb.Mcp.Tools.list_definitions/0`
- Do **not** add a duplicate tool with a slightly different name
If a tool already covers the capability, stop or extend it instead of creating
a new one.
### 2. Check if an API endpoint already exists
- Search `app/lib/operately_web/api.ex` for `query(:…)` / `mutation(:…)`
registrations
- Search `app/lib/operately_web/api/{domain}/` for matching modules
- Check `app/lib/operately_web/api/wrappers/` for API module wrappers, which behave like
usual endpoints
- Read sibling MCP tools in the same domain — they show the expected API alias
and input mapping
If the API exists and accepts the inputs MCP needs, skip to step 3 (wrapper
only).
### 3. Create endpoint first, then MCP wrapper
When no suitable API exists, **always API before MCP**:
1. Add API module under `app/lib/operately_web/api/{domain}/{action}.ex`
- Queries: `use TurboConnect.Query`
- Mutations: `use TurboConnect.Mutation` + `Action` pipeline
2. Register in `app/lib/operately_web/api.ex` as `query(:…)` or `mutation(:…)`
3. Add API tests under `app/test/operately_web/api/{domain}/{action}_test.exs`
using `OperatelyWeb.TurboCase` — cover auth, validation, permissions
4. Add MCP wrapper under `app/lib/operately_web/mcp/tools/{domain}/{action}.ex`
5. Add MCP unit test under
`app/test/operately_web/mcp/tools/{domain}/{action}_test.exs`
6. Update catalog test: add tool name to `@expected_tool_names` at the correct
position (`sort_order`, then alphabetical tie-break)
**Canonical example:** `list_task_statuses` — API was added first
(`app/lib/operately_web/api/tasks/list_task_statuses.ex` +
`app/test/operately_web/api/tasks/list_task_statuses_test.exs`), then the MCP
wrapper (`app/lib/operately_web/mcp/tools/tasks/list_task_statuses.ex` +
`app/test/operately_web/mcp/tools/tasks/list_task_statuses_test.exs`).
## MCP Tool Implementation
### Module contract
- `use OperatelyWeb.Mcp.Tool`
- Module: `OperatelyWeb.Mcp.Tools.{Domain}.{PascalCase}`
- File: `app/lib/operately_web/mcp/tools/{domain}/{snake_case}.ex`
- Public tool `name`: `snake_case` verb phrase (stable MCP API)
- Implement `definition/0` and `call/2`
- No manual registry edit — `OperatelyWeb.Mcp.Catalog.Registry` auto-discovers
compiled modules under `OperatelyWeb.Mcp.Tools.*`
### `definition/0` fields
| Field | Guidance |
| ----- | -------- |
| `company_mode` | `:none`, `:authenticated`, or `:resource_derived` — never accept `company_id` in input |
| `required_scopes` + `safety_classification` | Read → `["mcp:read"]` + `:read_only` + `read_annotations()`; Write → `["mcp:write"]` + `:write` + `write_annotations()` |
| `sort_order` | Catalog ordering band — see [reference.md](reference.md) |
| `input_schema` / `output_schema` | `JsonSchema.object/2`; string keys; `required` array only for truly required args |
| `examples` | At least one realistic example with encoded IDs like `"task_123"` |
| `discovery_metadata` | `%{"category" => "tasks"}` etc. |
### `call/2` adaptation
- Arguments arrive as **string-key maps**; API inputs use **atom keys**
- Always decode IDs via `OperatelyWeb.Mcp.Helpers.decode_id/1` /
`decode_optional_id/1` — never raw UUID parsing
- Return `{:ok, atom_key_map}` matching `output_schema` top-level keys, or
`{:error, :invalid_arguments | :not_found | :forbidden | …}`
- Use `{:error, :invalid_arguments}` for bad client input (malformed IDs,
conflicting scopes, invalid enums)
- **Optional field conventions** are domain-specific — check sibling tools:
- Omit optional field to clear (e.g. `update_project_due_date`: omit
`due_date` → clear)
- Use `Helpers.parse_day_date/1`, `markdown_to_rich_text/1`, `put_optional/3`
as appropriate
- **Enums**: validate against a whitelist before `String.to_existing_atom/1`,
or use explicit decode clauses (see `close_project`, `create_link`)
- **Hub scope**: exactly one of `space_id`, `project_id`, `goal_id` via
`Helpers.decode_hub_scope/1`; prefer `Api.Wrappers.DocsAndFiles.*` for
create/list operations
Do **not** call business logic directly from MCP — always go through API.
## Testing
Two test layers — **both required for new capabilities**:
| Layer | File pattern | Framework | What to test |
| ----- | ------------ | --------- | ------------ |
| API (exhaustive) | `app/test/operately_web/api/{domain}/{action}_test.exs` | `OperatelyWeb.TurboCase` | Auth, validation, permissions, happy paths |
| MCP (adaptation) | `app/test/operately_web/mcp/tools/{domain}/{action}_test.exs` | `Operately.DataCase` + `ToolConnHelper` | Call `ToolModule.call/2` directly; ID decoding; cross-company `not_found`; optional-field behavior |
MCP tests assume API correctness — do not re-test every permission matrix in MCP
tests.
**Catalog test (always update):** add name to `@expected_tool_names` in
`app/test/operately/mcp/tools_test.exs`. Missing entry fails CI.
Run targeted tests:
```bash
make test FILE=app/test/operately_web/api/tasks/list_task_statuses_test.exs
make test FILE=app/test/operately_web/mcp/tools/tasks/list_task_statuses_test.exs
make test FILE=app/test/operately/mcp/tools_test.exs
```
Optional integration: `app/test/operately_web/controllers/mcp_controller_test.exs`
for end-to-end HTTP — only when adding novel transport/auth behavior.
## Checklist
- [ ] Confirmed no existing MCP tool covers the capability
- [ ] API handler exists (or was added) and registered in `api.ex`
- [ ] API tests pass (`TurboCase`)
- [ ] MCP wrapper under `OperatelyWeb.Mcp.Tools.*` with correct schemas, scopes,
`company_mode`, `sort_order`
- [ ] MCP unit tests pass (call `ToolModule.call/2` via `ToolConnHelper`)
- [ ] `@expected_tool_names` updated in `tools_test.exs`
- [ ] Catalog test passes
- [ ] Optional fields / clear-on-omit behavior matches sibling tools in the
domain
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!