Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f5d50b6
feat: add auth.info viewer role check to dynamic tool list
claude Mar 7, 2026
c40fc02
docs: clarify dynamic tool list checks and fallback behavior
claude Mar 7, 2026
09f1619
fix: log actionable warning when 401 hides all tools
claude Mar 7, 2026
f4622c6
fix: log warning when apiKeys.list returns 403 (missing scope)
claude Mar 7, 2026
92d8191
fix: log warning when auth.info returns 403 (missing scope)
claude Mar 7, 2026
9c9f277
test: add e2e tests for viewer role + auth.info 403 behavior
claude Mar 7, 2026
64128a2
fix: create viewer API keys before role demotion
claude Mar 7, 2026
2944004
refactor: import WRITE_TOOL_NAMES in e2e tests to remove duplication
claude Mar 7, 2026
5c8383d
refactor: derive ALL_TOOLS from TOOL_ENDPOINT_MAP, add annotation com…
claude Mar 7, 2026
5d485d7
refactor: derive TOOL_ENDPOINT_MAP and WRITE_TOOL_NAMES from tool met…
claude Mar 7, 2026
842d69e
docs: add dynamic tool list architecture doc with mermaid diagrams
claude Mar 7, 2026
1c869e0
docs: fix sequential check ordering and remove redundant sections
claude Mar 7, 2026
1265043
refactor: replace readOnlyHint-based viewer check with explicit min_r…
claude Mar 7, 2026
f4a6ed0
fix: update e2e viewer test expected set for min_role changes
claude Mar 8, 2026
caf178f
fix: raise ValueError on invalid min_role in build_role_blocked_map
claude Mar 8, 2026
ea45bbf
test: verify build_role_blocked_map rejects invalid min_role
claude Mar 8, 2026
25cd8da
fix: harden dynamic tool filtering edge cases
claude Mar 8, 2026
d0f6f22
refactor: extract _get_scope_blocked_tools and harden auth.info 401 h…
claude Mar 8, 2026
a9581f1
fix: add retry and diagnostics for OIDC login in e2e fixtures
claude Mar 8, 2026
7ff053a
fix: use pre-computed last4 in log calls to avoid CodeQL alerts
claude Mar 8, 2026
849266f
fix: correct bcrypt hash for user@example.com in Dex config
claude Mar 8, 2026
a2e4bee
fix: add role change verification in viewer E2E fixture
claude Mar 8, 2026
e2ec124
fix: remove key fragment from logs and use correct role endpoint
claude Mar 8, 2026
af0b6c0
fix: use admin token for role verification after demotion
claude Mar 8, 2026
80a8913
fix: verify viewer API key validity after role demotion
claude Mar 8, 2026
aa06ada
fix: address review findings in dynamic tool list PR
claude Mar 8, 2026
eeca0ab
refactor: deduplicate E2E test helpers and export ROLE_LEVELS
claude Mar 9, 2026
4071524
docs: add GitHub Actions CI verification guidance to CLAUDE.md
claude Mar 9, 2026
7f33df7
fix: avoid sending empty JSON body in _outline_api when no payload ne…
claude Mar 9, 2026
21c0bba
fix: resolve CodeQL clear-text logging alerts in filtering.py
claude Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ register_all(mcp)
install_dynamic_tool_list(mcp) # If OUTLINE_DYNAMIC_TOOL_LIST=true
```

For dynamic tool list architecture and scope matching details, see
[docs/dynamic-tool-list.md](docs/dynamic-tool-list.md).

### MCP Resources (`outline://` URI scheme)

- `outline://document/{document_id}` - Full markdown content
Expand Down Expand Up @@ -190,7 +193,8 @@ async def new_operation(self, param: str) -> dict:
annotations=ToolAnnotations(
readOnlyHint=False,
destructiveHint=False,
)
),
meta={"endpoint": "namespace.method", "min_role": "member"},
)
async def new_tool_name(param: str) -> str:
"""Clear description."""
Expand All @@ -204,6 +208,17 @@ async def new_tool_name(param: str) -> str:
return f"Error: {str(e)}"
```

The `meta` dict requires two fields:
- `"endpoint"` — the Outline API endpoint (e.g. `documents.create`,
`collections.list`). Used for scope matching.
- `"min_role"` — minimum Outline role: `"viewer"`, `"member"`, or
`"admin"`. Used for role-based filtering. Verified against Outline
route handlers (`collections.ts`, `documents.ts`) and
`AuthenticationHelper.ts`.

The endpoint map and role-blocked map are derived automatically from
tool metadata by `introspect.py` — no separate map files need updating.

**Testing**: Mock OutlineClient, test success and error cases

## Technical Requirements
Expand Down Expand Up @@ -370,6 +385,52 @@ uv run poe test-integration
uv run poe test-e2e
```

### Verifying CI on GitHub

After pushing, verify all GitHub Actions checks pass. E2E tests run
in CI and cannot be fully replicated locally without the Docker
Compose E2E stack. Use the GitHub API to check status:

```bash
# Get status of all check runs for a commit
curl -s "https://api.github.com/repos/Vortiago/mcp-outline/commits/<SHA>/check-runs" \
-H "Accept: application/vnd.github+json" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for cr in data.get('check_runs', []):
print(f'{cr[\"name\"]}: {cr[\"status\"]}/{cr[\"conclusion\"]}')
print(f'Total: {data.get(\"total_count\", 0)}')
"
```

Replace `<SHA>` with the full or abbreviated commit hash. Expected
checks (all must show `completed/success`):

- **Unit Tests** (Python 3.10, 3.11, 3.12, 3.13)
- **CodeQL** (actions + python analyses)
- **E2E Tests** + E2E Test Report
- **Build**

If checks are still running (`in_progress` or `queued`),
wait and re-run the command. E2E tests typically take 2-4 minutes.

To get failure details (test annotations) for a specific check run:

```bash
# List failed test annotations for a check run
curl -s "https://api.github.com/repos/Vortiago/mcp-outline/check-runs/<CHECK_RUN_ID>/annotations" \
-H "Accept: application/vnd.github+json" \
| python3 -c "
import sys, json
for a in json.load(sys.stdin):
print(f'{a[\"path\"]}:{a[\"start_line\"]} - {a[\"message\"]}')
"
```

The `<CHECK_RUN_ID>` is available in the check-runs response
(`cr["id"]`).

## Common Patterns

**Pagination**: Use `offset` and `limit` parameters for large result sets
Expand Down
3 changes: 2 additions & 1 deletion config/dex-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ staticPasswords:
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
- email: "user@example.com"
# Password: user
hash: "$2a$10$DYY8A5a4z4kJ.XJ8nCw8h.vZJZGDqZlFLXjHLqBwYKvR5xXQQR5he"
# Generated with: python3 -c "import bcrypt; print(bcrypt.hashpw(b'user', bcrypt.gensalt(rounds=10)).decode())"
hash: "$2b$10$amYDhhAQjP3MPGkcbzbKUut.PXkJ0gbfjr1sMQNwrI315PmflJ/oO"
username: "user"
userID: "41331323-6f44-45e6-b3b9-465c8c5e3fe4"

Expand Down
15 changes: 10 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,16 @@ Then connect from your client with a user-specific key:
Set `OUTLINE_DYNAMIC_TOOL_LIST=true` to automatically filter the tool list based on each user's Outline role and API key scopes. This pairs well with per-user Outline API keys — each user sees only the tools their key allows.

**How it works:**
1. On each `tools/list` request, the server calls Outline's `auth.info` endpoint
2. If the user's role is `viewer`, write tools are hidden
3. If the API key has restricted scopes that exclude write endpoints, write tools are hidden
4. If `auth.info` fails for any reason, all tools are returned (fail-open)

**Note:** This is a convenience feature, not a security boundary. Even if a tool is hidden from the list, Outline's own API enforces permissions on individual operations.
On each `tools/list` request, the server performs two independent checks:

1. **Role check** (`auth.info`) — tools requiring a higher role than the user's are hidden (e.g. viewers cannot see member/admin tools)
2. **Scope check** (`apiKeys.list`) — if the API key has restricted scopes, tools for excluded endpoints are hidden. See the [Outline API documentation](https://www.getoutline.com/developers) for details on scope formats and available scopes.

Both results are combined. Each check fails open independently — if either call fails (e.g. the key lacks `apiKeys.list` scope), that check is skipped and all tools remain visible. The only exception is a 401 (invalid key), which hides all tools.

> **Note:** This is a convenience feature, not a security boundary. Even if a tool is hidden from the list, Outline's own API enforces permissions on individual operations.

This feature composes with `OUTLINE_READ_ONLY` and `OUTLINE_DISABLE_DELETE`. If `OUTLINE_READ_ONLY=true`, write tools are never registered regardless of this setting.

For architecture details and diagrams, see [Dynamic Tool List Architecture](dynamic-tool-list.md).
193 changes: 193 additions & 0 deletions docs/dynamic-tool-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# Dynamic Tool List — Architecture

Per-user filtering of the MCP `tools/list` response based on API key scopes and Outline user role. Disabled by default; enable with `OUTLINE_DYNAMIC_TOOL_LIST=true`.

For configuration and setup, see [Configuration — Dynamic Tool List](configuration.md#dynamic-tool-list).

## Architecture Overview

The system has two phases: **startup** (build metadata maps from tool decorators) and **runtime** (filter `tools/list` per request).

```mermaid
flowchart TB
subgraph Startup
A["register_all(mcp)"] --> B["build_tool_endpoint_map()"]
A --> C["build_role_blocked_map()"]
B --> D["install_dynamic_tool_list(mcp, maps)"]
C --> D
D --> E["Wrap tools/list handler"]
end

subgraph Runtime ["Runtime (per request)"]
F["tools/list request"] --> G["Resolve API key"]
G --> H["Check 1: Role (auth.info)"]
H --> I["Check 2: Scopes (apiKeys.list)"]
H --> J["Union blocked sets"]
I --> J
J --> K["Filter tool list"]
K --> L["Return visible tools"]
end

E -.-> F
```

## Startup: Metadata Introspection

Every `@mcp.tool()` decorator carries metadata that drives filtering:

```python
@mcp.tool(
annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=True),
meta={"endpoint": "documents.delete", "min_role": "member"},
)
async def delete_document(...) -> str:
```

After `register_all(mcp)`, `introspect.py` scans all registered tools via `mcp._tool_manager._tools` and builds:

| Map | Source | Purpose |
|-----|--------|---------|
| `tool_endpoint_map` | `meta["endpoint"]` | `{tool_name: "namespace.method"}` — used for scope matching |
| `role_blocked_map` | `meta["min_role"]` | `{role: frozenset(blocked_names)}` — used for role-based blocking |

`min_role` declares the minimum Outline role required (`"viewer"`, `"member"`, or `"admin"`). This is independent of `readOnlyHint`, which controls `OUTLINE_READ_ONLY` module registration.

These maps are passed to `install_dynamic_tool_list()`, which wraps the `tools/list` protocol handler with a filtering function.

## Runtime: Per-Request Filtering

On each `tools/list` call, the wrapped handler:

1. Resolves the API key from `x-outline-api-key` header (HTTP) or `OUTLINE_API_KEY` env var (stdio)
2. Runs two checks sequentially (role, then scopes)
3. Unions the blocked sets
4. Filters out blocked tools from the response

### Check 1 — Role-Based Filtering

Calls `auth.info` to get the user's Outline role, then looks up blocked tools from `role_blocked_map` (built from `min_role` metadata at startup).

```mermaid
flowchart TD
A["Call auth.info"] --> B{Response?}
B -->|200| C["Look up role in\nrole_blocked_map"]
C --> D{role in map?}
D -->|Yes| E["Block tools above\nuser's role level"]
D -->|No| F["Block nothing"]
B -->|"403 (missing scope)"| G["Log WARNING\nBlock nothing"]
B -->|Other error| F
```

**Fail-open**: if `auth.info` errors, no tools are blocked by this check. A 403 specifically logs a warning suggesting the operator add `auth.info` to the key's scope array.

### Check 2 — Scope-Based Filtering

Calls `apiKeys.list`, finds the key by its last 4 characters, reads the `scope` array, then checks each tool's endpoint against the scopes.

```mermaid
flowchart TD
A["Call apiKeys.list"] --> B{Response?}
B -->|"401 (invalid key)"| C["Block ALL tools"]
B -->|"403 (missing scope)"| D["Log WARNING\nBlock nothing"]
B -->|Other error| D
B -->|200| E["Match key by last4"]
E --> F{Key found?}
F -->|No| D
F -->|Yes| G{scope == null?}
G -->|"null (full access)"| D
G -->|"[scopes...]"| H["For each tool:\nis_endpoint_accessible(endpoint, scopes)?"]
H --> I["Block inaccessible tools"]
```

**401 is special**: it means the key is invalid, expired, or revoked — *all* tools are hidden. Every other error fails open.

**last4 collision**: if multiple keys share the same last 4 digits, their scopes are unioned. If any matching key has `scope: null` (full access), the result is treated as full access.

## Scope Matching Algorithm

Mirrors Outline's [`AuthenticationHelper.canAccess`](https://github.com/outline/outline/blob/main/shared/helpers/AuthenticationHelper.ts). Implementation: `scope_matching.py`.

### Scope Formats

**Route scopes** — `/api/namespace.method`

Exact match with `*` wildcard support:

| Scope | Matches |
|-------|---------|
| `/api/documents.info` | `documents.info` only |
| `/api/documents.*` | Any method on `documents` |
| `/api/*.*` | Everything |

**Namespaced scopes** — `namespace:level`

The `level` determines which methods are accessible via a `methodToScope` mapping:

| Method | Maps to scope |
|--------|---------------|
| `create` | `create` |
| `config`, `list`, `info`, `search`, `documents`, `drafts`, `viewed`, `export` | `read` |
| Everything else (`update`, `delete`, `archive`, `restore`, `move`, `redirect`, `export_all`, `answerQuestion`, ...) | `write` (default) |

Level matching:

| Level | Grants |
|-------|--------|
| `read` | Methods that map to `read` |
| `create` | Only the `create` method |
| `write` | All methods (superset of read + create) |

**Wildcard namespace**: `*:read` matches the `read` level on any namespace.

### Gotchas

- `attachments:read` does **not** grant `attachments.redirect` — `redirect` defaults to `write`
- `collections:read` does **not** grant `collections.export_all` — `export_all` defaults to `write`
- Methods that default to `write` scope (not in `methodToScope`): `update`, `delete`, `archive`, `restore`, `move`, `redirect`, `export_all`, `answerQuestion`, `archived`, `deleted`
- Global scopes like `"read"` are broken in Outline v1.5.0 (storage normalisation prepends `/api/`). Use namespaced scopes (`documents:read`) instead

## Interaction with `OUTLINE_READ_ONLY`

When `OUTLINE_READ_ONLY=true`, write modules (content, lifecycle, organization, batch operations) are **never registered**. The dynamic tool list only filters tools that are actually registered, so `min_role` has no effect on tools that were excluded at startup. The two systems are independent:

- `OUTLINE_READ_ONLY` controls which modules are **registered** (startup-time, all-or-nothing)
- `min_role` controls which tools are **visible per-user** (request-time, role-based)

If both are set, `OUTLINE_READ_ONLY` takes precedence — unregistered tools cannot be shown regardless of the user's role.

## Error Handling

The system is **fail-open by design** — if any check fails, that check is skipped and no tools are blocked by it. This is intentional: the dynamic tool list is a UX convenience, not a security boundary. Outline's API enforces permissions on individual operations regardless.

The single exception is **401 on `apiKeys.list`**, which indicates the key is invalid/expired/revoked. In this case, all tools are hidden to avoid showing tools that will all fail anyway.

| Scenario | Behavior |
|----------|----------|
| `auth.info` returns 403 | Log warning, skip role check |
| `auth.info` returns other error | Skip role check |
| `apiKeys.list` returns **401** | **Block all tools** (unioned with role blocks) |
| `apiKeys.list` returns 403 | Log warning, skip scope check |
| `apiKeys.list` returns other error | Skip scope check |
| Key not found by last4 | Skip scope check |
| Client init fails | Return full tool list |
| Any unexpected exception in scope check | Skip scope check (role blocks preserved) |

## Module Structure

```
src/mcp_outline/features/dynamic_tools/
├── __init__.py # Public exports
├── introspect.py # Startup: build maps from tool metadata
├── filtering.py # Runtime: per-request filtering logic
├── scope_matching.py # Pure functions: Outline's scope algorithm
└── CLAUDE.md # LLM-oriented reference
```

## Adding a New Tool

No changes needed in the dynamic tools module. Just ensure the `@mcp.tool()` decorator has:

1. **`meta={"endpoint": "namespace.method"}`** — the Outline API endpoint for scope matching
2. **`meta={"min_role": "viewer"|"member"|"admin"}`** — the minimum Outline role required

The maps are built automatically at startup. Integration tests (`test_all_tools_have_endpoint_meta`, `test_all_tools_have_min_role_meta`) verify every registered tool has both.
6 changes: 5 additions & 1 deletion src/mcp_outline/features/documents/ai_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ def register_tools(mcp) -> None:
@mcp.tool(
annotations=ToolAnnotations(
readOnlyHint=True, openWorldHint=True, idempotentHint=False
)
),
meta={
"endpoint": "documents.answerQuestion",
"min_role": "viewer",
},
)
async def ask_ai_about_documents(
question: str,
Expand Down
30 changes: 25 additions & 5 deletions src/mcp_outline/features/documents/batch_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,11 @@ def register_tools(mcp) -> None:
readOnlyHint=False,
destructiveHint=True,
idempotentHint=True,
)
),
meta={
"endpoint": "documents.archive",
"min_role": "member",
},
)
async def batch_archive_documents(document_ids: List[str]) -> str:
"""
Expand Down Expand Up @@ -212,7 +216,11 @@ async def batch_archive_documents(document_ids: List[str]) -> str:
readOnlyHint=False,
destructiveHint=True,
idempotentHint=True,
)
),
meta={
"endpoint": "documents.move",
"min_role": "member",
},
)
async def batch_move_documents(
document_ids: List[str],
Expand Down Expand Up @@ -324,7 +332,11 @@ async def batch_move_documents(
readOnlyHint=False,
destructiveHint=True,
idempotentHint=True,
)
),
meta={
"endpoint": "documents.delete",
"min_role": "member",
},
)
async def batch_delete_documents(
document_ids: List[str], permanent: bool = False
Expand Down Expand Up @@ -444,7 +456,11 @@ async def batch_delete_documents(
readOnlyHint=False,
destructiveHint=True,
idempotentHint=True,
)
),
meta={
"endpoint": "documents.update",
"min_role": "member",
},
)
async def batch_update_documents(updates: List[Dict[str, Any]]) -> str:
"""
Expand Down Expand Up @@ -562,7 +578,11 @@ async def batch_update_documents(updates: List[Dict[str, Any]]) -> str:
readOnlyHint=False,
destructiveHint=True,
idempotentHint=True,
)
),
meta={
"endpoint": "documents.create",
"min_role": "member",
},
)
async def batch_create_documents(documents: List[Dict[str, Any]]) -> str:
"""
Expand Down
Loading