Skip to content

Commit 8f3de2b

Browse files
authored
feat: replace endpoint probing with apiKeys.list scope matching (#73)
* feat: replace endpoint probing with apiKeys.list scope matching Replace the dynamic tool list's endpoint-probing approach (which made ~15 concurrent HTTP requests with fake UUIDs) with a single call to Outline's apiKeys.list endpoint. The key's stored scopes are matched locally using Outline's canAccess algorithm to determine which tools to show. Scoped API keys must include apiKeys.list in their scope array for introspection to work. Without it the feature degrades gracefully (shows all tools). - Add OutlineClient.list_api_keys() method - Add scope_matching.py implementing Outline's canAccess algorithm - Rewrite get_blocked_tools() to use scope-based filtering - 401 from apiKeys.list = invalid key (block all tools) - 403/other errors = fail-open (show all tools) - Match current key by last4 characters - Update E2E tests to include apiKeys.list scope - Add comprehensive unit tests for scope matching
1 parent ec35de4 commit 8f3de2b

File tree

12 files changed

+1593
-1229
lines changed

12 files changed

+1593
-1229
lines changed

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ install_dynamic_tool_list(mcp) # If OUTLINE_DYNAMIC_TOOL_LIST=
8282
- Comments: create, list, get
8383
- Attachments: get_redirect_url, fetch_content
8484
- AI: answer questions
85-
- Auth: probe_endpoint (endpoint connectivity verification)
85+
- API Keys: list_api_keys (scope introspection for dynamic tool list)
8686

8787
**Connection Pooling**:
8888
- Uses httpx with class-level connection pool
@@ -318,7 +318,7 @@ OUTLINE_WRITE_TIMEOUT=30.0 # Write timeout in seconds
318318
OUTLINE_DISABLE_AI_TOOLS=true # Disable AI tools
319319
OUTLINE_READ_ONLY=true # Disable all write operations
320320
OUTLINE_DISABLE_DELETE=true # Disable delete operations only
321-
OUTLINE_DYNAMIC_TOOL_LIST=true # Enable per-request tool filtering (off by default)
321+
OUTLINE_DYNAMIC_TOOL_LIST=true # Enable per-request tool filtering (requires apiKeys.list scope)
322322

323323
# MCP server (optional)
324324
MCP_TRANSPORT=stdio # Transport: stdio, sse, streamable-http
@@ -329,7 +329,7 @@ MCP_PORT=3000 # Server port
329329
**Access Control Notes**:
330330
- `OUTLINE_READ_ONLY`: Blocks entire write modules at registration (content, lifecycle, organization, batch_operations)
331331
- `OUTLINE_DISABLE_DELETE`: Conditionally registers delete tools within document_lifecycle and collection_tools
332-
- `OUTLINE_DYNAMIC_TOOL_LIST`: Off by default. Filters tools per-request based on endpoint probing and API key scopes. Fail-open: if endpoint probe fails, all tools are shown. Set to `true` to enable.
332+
- `OUTLINE_DYNAMIC_TOOL_LIST`: Off by default. Uses `apiKeys.list` to introspect API key scopes and filters tools per-request based on scope matching. Scoped API keys must include `apiKeys.list` in their scope array for introspection to work. Fail-open: if scope introspection fails, all tools are shown. Set to `true` to enable.
333333
- Read-only mode takes precedence: If both are set, server operates in read-only mode
334334

335335
### Critical Requirements
Lines changed: 17 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,19 @@
1-
# Dynamic Tool List — Outline Scope Reference
1+
# Dynamic Tool List
22

3-
How Outline checks API key scopes against request endpoints.
3+
Filters the MCP `tools/list` response per-request based on the API
4+
key's scopes. Calls `apiKeys.list` once, matches the key by `last4`,
5+
then applies Outline's scope matching algorithm locally.
46

5-
## Outline Source Files
7+
## Scope Matching
68

7-
- **Scope enum** (`Scope.Read`, `Scope.Write`, `Scope.Create`):
8-
<https://github.com/outline/outline/blob/main/shared/types.ts>
9-
- **`canAccess(path, scopes)`** — path-to-scope matching logic:
10-
<https://github.com/outline/outline/blob/main/shared/helpers/AuthenticationHelper.ts>
11-
- **`ApiKey` model** — scope stored as `DataType.ARRAY(DataType.STRING)`:
12-
<https://github.com/outline/outline/blob/main/server/models/ApiKey.ts>
13-
- **Authentication middleware** — calls `apiKey.canAccess(ctx.originalUrl)`:
14-
<https://github.com/outline/outline/blob/main/server/middlewares/authentication.ts>
15-
- **`apiKeys.create` handler** — normalises scopes on save:
16-
<https://github.com/outline/outline/blob/main/server/routes/api/apiKeys/apiKeys.ts>
17-
- **API key scopes feature** (issue + PR):
18-
<https://github.com/outline/outline/issues/8186>
19-
<https://github.com/outline/outline/pull/8297>
9+
Mirrors `AuthenticationHelper.canAccess` from
10+
[Outline source](https://github.com/outline/outline/blob/main/shared/helpers/AuthenticationHelper.ts).
11+
See `scope_matching.py` for the implementation.
2012

21-
## Scope Storage Normalisation (apiKeys.create)
13+
Two scope formats:
2214

23-
Before persisting, the server transforms each scope entry:
24-
25-
```typescript
26-
scope?.map((s) =>
27-
s.startsWith("/api/") || s.includes(":")
28-
? s // keep as-is
29-
: `/api/${s.replace(/^\\/, "")}` // prepend /api/
30-
)
31-
```
32-
33-
- `"documents.list"``"/api/documents.list"` (route scope)
34-
- `"auth.info"``"/api/auth.info"` (route scope)
35-
- `"documents:read"``"documents:read"` (namespaced, kept as-is)
36-
- `"read"``"/api/read"` (becomes broken route scope — see below)
37-
38-
## Scope Matching Algorithm (AuthenticationHelper.canAccess)
39-
40-
Given a request path and stored scopes, for each scope token:
41-
42-
1. Extract `resource` from path: `/api/documents.create`
43-
`namespace = "documents"`, `method = "create"`
44-
2. Parse scope format:
45-
46-
### Route scopes (start with `/api/`)
47-
48-
Stored as `/api/namespace.method`. Matched by exact namespace + method:
49-
50-
```
51-
(namespace === scopeNamespace || scopeNamespace === "*") &&
52-
(method === scopeMethod || scopeMethod === "*")
53-
```
54-
55-
Examples: `/api/documents.list` matches only `documents.list`.
56-
57-
### Namespaced scopes (contain `:`)
58-
59-
Format `namespace:level` where level is `read`, `write`, or `create`.
60-
Matched against the `methodToScope` mapping:
61-
62-
```
63-
(namespace === scopeNamespace || scopeNamespace === "*") &&
64-
(scopeMethod === "write" || methodToScope[method] === scopeMethod)
65-
```
66-
67-
- `documents:read` → grants `documents.list`, `documents.info`,
68-
`documents.search`, `documents.export` (methods mapped to `read`)
69-
- `documents:write` → grants ALL document endpoints (write matches
70-
everything)
71-
- `documents:create` → grants only `documents.create`
72-
73-
### Global scopes (no `:` or `.`)
74-
75-
Parsed as `scopeNamespace = "*"`, `scopeMethod = scope`. Same matching
76-
as namespaced but with wildcard namespace.
77-
78-
- `read` → all read methods across all namespaces
79-
- `write` → all endpoints (write matches everything)
80-
81-
**Outline bug (v1.5.0)**: global scopes like `"read"` get `/api/`
82-
prepended by the storage normalisation → stored as `"/api/read"`
83-
treated as a route scope for namespace `"read"` which matches nothing →
84-
401 on every endpoint. Namespaced scopes (`documents:read`) are not
85-
affected because the `:` bypasses the `/api/` prepend.
86-
87-
### methodToScope mapping
15+
- **Route scopes** (`/api/namespace.method`) — exact match with `*` wildcards
16+
- **Namespaced scopes** (`namespace:level`) — matched via `methodToScope`:
8817

8918
```
9019
create → "create"
@@ -99,21 +28,10 @@ export → "read"
9928
(other) → defaults to "write"
10029
```
10130

102-
## `auth.info` and Scope Detection
31+
`redirect` and `export_all` are not in the mapping and default to
32+
`write`. So `attachments:read` and `collections:read` do **not**
33+
grant access to `attachments.redirect` or `collections.export_all`.
10334

104-
- `auth.info` does NOT return the API key's scope in its response —
105-
the `data.apiKey` field is absent.
106-
- To detect whether a key has write access, probe a write endpoint
107-
(e.g. `documents.create`) and check for 401.
108-
109-
## Probing: 401 vs 403
110-
111-
- **401** (`authentication_required`): the API key's scope does not
112-
include this endpoint. The authentication middleware rejects the
113-
request *before* the route handler runs. This is what probing
114-
detects.
115-
- **403** (`authorization_error`): the key is authenticated and
116-
in-scope, but the specific resource is inaccessible (e.g. the
117-
probed UUID doesn't match a real document). Outline returns 403
118-
instead of 404 for non-existent resources to avoid leaking
119-
existence information. Probing must **not** treat 403 as blocked.
35+
**Outline bug (v1.5.0)**: global scopes like `"read"` get `/api/`
36+
prepended by storage normalisation and become broken route scopes.
37+
Use namespaced scopes (`documents:read`) instead.

src/mcp_outline/features/dynamic_tools/__init__.py

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,9 @@
1-
"""Dynamic tool list filtering based on Outline user permissions.
1+
"""Dynamic tool list filtering based on Outline API key scopes.
22
3-
When enabled, the MCP ``tools/list`` response is filtered per-request
4-
by probing each Outline API endpoint with the authenticated API key.
5-
Endpoints returning 401 cause their associated MCP tools to be
6-
hidden. 403 is *not* treated as blocked — Outline returns 403 for
7-
resource-level authorization (e.g. non-existent UUID), while scope
8-
restrictions produce 401.
9-
10-
The feature is **off by default**. Enable it by setting
11-
``OUTLINE_DYNAMIC_TOOL_LIST`` to ``true``, ``1``, or ``yes``
12-
(case-insensitive).
13-
14-
This module is intentionally fail-open: if probing fails for any
15-
reason the full tool list is returned. Outline's own API will
16-
still enforce permissions on individual tool calls.
17-
18-
Outline references
19-
------------------
20-
- User roles (admin / member / viewer / guest):
21-
https://docs.getoutline.com/s/guide/doc/users-roles-cwCxXP8R3V
22-
- ``UserRole`` enum in source (``shared/types.ts``):
23-
https://github.com/outline/outline/blob/main/shared/types.ts
24-
- API key scopes (space-separated endpoint prefixes, added in v0.82):
25-
https://github.com/outline/outline/issues/8186
26-
https://github.com/outline/outline/pull/8297
27-
- Full API endpoint list (OpenAPI spec):
28-
https://github.com/outline/openapi/blob/main/spec3.yml
29-
- Interactive API reference:
30-
https://www.getoutline.com/developers
3+
Filters MCP ``tools/list`` per-request using ``apiKeys.list`` scope
4+
introspection. Off by default; enable with
5+
``OUTLINE_DYNAMIC_TOOL_LIST=true``. Fail-open: if introspection
6+
fails, the full tool list is returned.
317
"""
328

339
from mcp_outline.features.dynamic_tools.filtering import (

src/mcp_outline/features/dynamic_tools/filtering.py

Lines changed: 71 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,29 @@
1-
"""Filtering logic for dynamic tool list via endpoint probing."""
1+
"""Filtering logic for dynamic tool list via API key scopes."""
22

33
from __future__ import annotations
44

55
import logging
66
import os
7-
from typing import TYPE_CHECKING, Dict, List, Optional, Set
7+
from typing import TYPE_CHECKING, List, Optional, Set
88

9-
import anyio
109
from mcp.types import Tool as MCPTool
1110

1211
from mcp_outline.features.documents.common import (
1312
_get_header_api_key,
1413
)
14+
from mcp_outline.features.dynamic_tools.scope_matching import (
15+
blocked_tools_for_scopes,
16+
)
1517
from mcp_outline.features.dynamic_tools.tool_endpoint_map import (
1618
TOOL_ENDPOINT_MAP,
1719
)
18-
from mcp_outline.utils.outline_client import OutlineClient
20+
from mcp_outline.utils.outline_client import OutlineClient, OutlineError
1921

2022
if TYPE_CHECKING:
2123
from mcp.server.fastmcp import FastMCP
2224

2325
logger = logging.getLogger(__name__)
2426

25-
# Per-key cache: API key scopes are immutable (revoke + recreate
26-
# to change), so probe results are cached for the process lifetime.
27-
_blocked_cache: Dict[str, Set[str]] = {}
28-
2927

3028
# ------------------------------------------------------------------
3129
# Helpers
@@ -45,55 +43,69 @@ async def get_blocked_tools(
4543
api_key: Optional[str],
4644
api_url: Optional[str],
4745
) -> Set[str]:
48-
"""Determine which tools *api_key* cannot access.
49-
50-
Results are cached per API key for the process lifetime
51-
since Outline key scopes are immutable.
46+
"""Return tool names *api_key* cannot access.
5247
53-
Probes all unique endpoints in ``TOOL_ENDPOINT_MAP``
54-
concurrently. Endpoints returning 401 are mapped back
55-
to their tool names, which are returned as the blocked set.
56-
57-
Fail-open: returns an empty set on any unexpected error.
48+
Calls ``apiKeys.list``, matches by ``last4``, then applies
49+
scope matching locally. Fail-open on errors (except 401
50+
which blocks all tools).
5851
"""
5952
if not api_key:
6053
return set()
6154

62-
if api_key in _blocked_cache:
63-
return _blocked_cache[api_key]
64-
6555
try:
6656
client = OutlineClient(api_key=api_key, api_url=api_url)
67-
68-
# Collect unique endpoints and their tool mappings
69-
endpoint_to_tools: Dict[str, List[str]] = {}
70-
for tool_name, endpoint in TOOL_ENDPOINT_MAP.items():
71-
endpoint_to_tools.setdefault(endpoint, []).append(tool_name)
72-
73-
unique_endpoints = list(endpoint_to_tools.keys())
74-
75-
# Probe all endpoints concurrently
76-
probe_results: Dict[str, bool] = {}
77-
78-
async def _probe_one(ep: str) -> None:
79-
probe_results[ep] = await client.probe_endpoint(ep)
80-
81-
async with anyio.create_task_group() as tg:
82-
for ep in unique_endpoints:
83-
tg.start_soon(_probe_one, ep)
84-
85-
# Map blocked endpoints back to tool names
86-
blocked: Set[str] = set()
87-
for endpoint in unique_endpoints:
88-
if not probe_results.get(endpoint, True):
89-
blocked.update(endpoint_to_tools[endpoint])
90-
91-
_blocked_cache[api_key] = blocked
92-
return blocked
57+
last4 = api_key[-4:]
58+
59+
# Fetch API keys, paginating if the key isn't in
60+
# the first page.
61+
scopes: Optional[List[str]] = None
62+
found = False
63+
offset = 0
64+
limit = 100
65+
while True:
66+
try:
67+
keys = await client.list_api_keys(limit=limit, offset=offset)
68+
except OutlineError as e:
69+
if e.status_code == 401:
70+
# Key is completely invalid → block all.
71+
return set(TOOL_ENDPOINT_MAP.keys())
72+
# 403 / other → fail-open.
73+
logger.debug(
74+
"apiKeys.list failed (%s), returning full tool list",
75+
e,
76+
)
77+
return set()
78+
79+
for key_data in keys:
80+
if key_data.get("last4") == last4:
81+
key_scope = key_data.get("scope")
82+
if not found:
83+
scopes = key_scope
84+
found = True
85+
elif key_scope is None:
86+
# Full-access key wins.
87+
scopes = None
88+
elif scopes is not None:
89+
# last4 collision → union.
90+
scopes = list(set(scopes) | set(key_scope))
91+
92+
if len(keys) < limit:
93+
break
94+
offset += limit
95+
96+
if not found:
97+
logger.debug(
98+
"API key last4=%s not found in "
99+
"apiKeys.list, returning full tool list",
100+
last4,
101+
)
102+
return set()
103+
104+
return blocked_tools_for_scopes(scopes)
93105

94106
except Exception as exc:
95107
logger.debug(
96-
"Dynamic tool list: endpoint probing failed (%s),"
108+
"Dynamic tool list: scope check failed (%s),"
97109
" returning full tool list",
98110
exc,
99111
)
@@ -109,7 +121,7 @@ def install_dynamic_tool_list(mcp: "FastMCP") -> None:
109121
"""Install per-request tool filtering on *mcp*.
110122
111123
Re-registers the lowlevel ``tools/list`` handler so that
112-
tools whose endpoints are blocked (401) are hidden.
124+
tools blocked by the API key's scopes are hidden.
113125
Disabled by default; set ``OUTLINE_DYNAMIC_TOOL_LIST=true``
114126
to enable.
115127
@@ -123,13 +135,19 @@ def install_dynamic_tool_list(mcp: "FastMCP") -> None:
123135
async def filtered_list_tools() -> List[MCPTool]:
124136
tools: List[MCPTool] = await original_list_tools()
125137

126-
api_key = _get_header_api_key() or os.getenv("OUTLINE_API_KEY")
127-
api_url = os.getenv("OUTLINE_API_URL")
138+
try:
139+
api_key = _get_header_api_key() or os.getenv("OUTLINE_API_KEY")
140+
api_url = os.getenv("OUTLINE_API_URL")
128141

129-
blocked = await get_blocked_tools(api_key, api_url)
142+
blocked = await get_blocked_tools(api_key, api_url)
130143

131-
if blocked:
132-
return [t for t in tools if t.name not in blocked]
144+
if blocked:
145+
return [t for t in tools if t.name not in blocked]
146+
except Exception as exc:
147+
logger.debug(
148+
"Dynamic tool filtering failed (%s), returning full tool list",
149+
exc,
150+
)
133151

134152
return tools
135153

0 commit comments

Comments
 (0)