mcp-multisource
# mcp-multisource
An MCP server that federates **GitHub**, **Notion** and **Linear** behind one
access-controlled tool surface, with per-source failure isolation.
It is built around a claim that is easy to state and annoying to implement: a
model should be able to ask one question that spans three systems, get an
answer when only two of them are up, and never be able to write to a system it
was not scoped to.
```
┌──────────────────────────┐
MCP client ────────▶ │ AccessControlMiddleware │ role + resource scoping
(Claude Desktop) └────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ tool surface │ 16 tools, each declaring
└──┬──────────┬──────────┬─┘ the capability it needs
│ │ │
┌──────▼───┐ ┌────▼─────┐ ┌──▼───────┐
│ GitHub │ │ Notion │ │ Linear │ one adapter each:
│ adapter │ │ adapter │ │ adapter │ own auth, own rate
└────┬─────┘ └────┬─────┘ └────┬─────┘ dialect, own breaker
│ │ │
REST REST+ver GraphQL
```
---
## What it does
Sixteen tools. Five read GitHub, three read/write Notion, four read/write
Linear, one joins all three, three are metadata.
The tool that justifies the architecture is `release_readiness`:
```
release_readiness(repo="acme/service", spec_query="checkout rewrite", team_key="ENG")
```
It fans out concurrently to open PRs and issues on GitHub, matching spec pages
in Notion, and tracked work in Linear — then returns a single object with a
`degraded` list naming any section it could not fill and why. A model calling
it during a Notion outage still gets the GitHub and Linear halves plus an
explicit note that the spec section is missing, instead of an error.
| Tool | Source | Capability |
|---|---|---|
| `github_list_repos` | GitHub | read |
| `github_list_issues` | GitHub | read |
| `github_list_pull_requests` | GitHub | read |
| `github_search_code` | GitHub | read |
| `github_comment_on_issue` | GitHub | **write** |
| `notion_search` | Notion | read |
| `notion_get_page_text` | Notion | read |
| `notion_create_page` | Notion | **write** |
| `linear_list_teams` | Linear | read |
| `linear_list_issues` | Linear | read |
| `linear_create_issue` | Linear | **write** |
| `linear_comment_on_issue` | Linear | **write** |
| `release_readiness` | all three | read |
| `system_health` | — | read |
| `access_policy` | — | read |
| `source_diagnostics` | — | **admin** |
---
## Why these three sources
The brief for this project asks for a structured source, an unstructured
knowledge source, and an action-capable source. That is not an arbitrary
taxonomy — it is the minimum set needed to answer the question an engineering
org actually asks, which is some variant of *"is this thing done, and if not,
what is left?"*
Answering it requires three different kinds of lookup:
- **GitHub — structured ground truth.** What the code actually says. PRs,
issues, branches. Machine-shaped, queryable, and not subject to opinion.
- **Notion — unstructured intent.** What the thing was *supposed* to do. Specs,
runbooks, decision docs, postmortems. Prose written by humans for humans;
there is no schema to query, so it has to be searched and flattened.
- **Linear — action.** Where the gap between the first two becomes somebody's
problem. This is the source the server can *write* to, which is why it is
also the one with the tightest access control.
Each one is a genuinely different integration problem, which is the point. They
do not share an auth scheme, a rate-limit dialect, an error convention, or even
a protocol — Linear is GraphQL, the others are REST, and Notion's REST is
version-pinned by header rather than by URL. Wrapping three REST APIs from the
same vendor would demonstrate nothing. Every meaningful difference between
these three forced a design decision that is documented below.
**The use case they compose into:** an engineering assistant. *"Read the
checkout rewrite spec, tell me which parts have no PR yet, and file Linear
issues for the gaps."* That single request touches unstructured knowledge,
structured state, and a write — one from each column.
---
## Authentication
Three sources, three schemes. None of them interchangeable, and two of them
have a trap.
| Source | Header | Notes |
|---|---|---|
| GitHub | `Authorization: Bearer <fine-grained PAT>` | Also requires `X-GitHub-Api-Version`. Fine-grained tokens are per-repo — a token scoped to one repo returns **404, not 403**, for the others, which reads like a typo. |
| Notion | `Authorization: Bearer <integration secret>` **+** `Notion-Version: 2022-06-28` | The version header is **mandatory**. Notion pins behaviour to a date string rather than negotiating; omit it and every call 400s. |
| Linear | `Authorization: <personal API key>` | **No `Bearer` prefix.** Adding one — the reflex, since the other two want it — fails with an unhelpful error. This is the single most common Linear integration mistake. |
**The Notion sharing trap.** An integration token that authenticates perfectly
still sees an empty workspace until a human explicitly shares each page with
it (`page → ⋯ → Connections → <integration>`). Notion reports this as
`404 object_not_found`, which is indistinguishable from a bad page id unless
you know. The adapter intercepts that specific code and rewrites the message to
name the real cause — see `sources/notion.py`. One line of code, an hour of
debugging saved, and a good example of why per-source adapters beat a generic
HTTP wrapper.
Credentials live in `.env` (gitignored), are read once in `config.py`, and are
never logged. `source_diagnostics` reports *whether* each credential is present
and what scheme it uses — never a value.
---
## Access control
Most MCP examples expose every tool to every caller. That is fine for a demo
and untenable the moment the server points at a company's real Linear
workspace. This server gates on two axes.
**Capabilities.** Three roles, set by `MCP_ROLE`:
| Role | read | write | admin |
|---|:--:|:--:|:--:|
| `viewer` | ✅ | ❌ | ❌ |
| `contributor` | ✅ | ✅ | ❌ |
| `admin` | ✅ | ✅ | ✅ |
An unrecognised role **fails closed** to `viewer` rather than erroring or
defaulting open.
**Resource scoping.** Capability is not enough — a contributor allowed to write
should still not be able to write *anywhere*. `ALLOWED_REPOS`,
`ALLOWED_LINEAR_TEAMS` and `ALLOWED_NOTION_PAGES` scope a deployment to
specific resources. Empty means unscoped. Repo matching is case-insensitive
(GitHub is); Notion page ids are compared with dashes stripped, because Notion
hands out the same id in both forms.
**Enforced in two places, on purpose.** `AccessControlMiddleware` sits on the
SDK's `ServerMiddleware` hook, which runs before validation or handler dispatch
on every inbound request:
- On `tools/list` it **filters** the advertised tools. This is prompt hygiene —
the model never burns a turn on a tool it cannot call.
- On `tools/call` it **authorizes** independently. This is the actual boundary.
A list filter is not security: a client can call any tool name it likes,
including one it was never shown. `tests/test_access.py` asserts exactly this
case.
Requirements are declarative. Each tool carries a `ToolAccess` in its MCP
`meta`, so a new tool cannot silently ship without a policy — there is no
"remembered to add the check" failure mode:
```python
register(linear_create_issue, ToolAccess(WRITE, "linear", "team_key", "linear_team"), ...)
```
A denial comes back as a structured `isError` tool result carrying
`retryable: false`, not a protocol exception. The model reads *why* it was
denied and stops, instead of seeing a transport failure and retrying.
---
## What happens when a source is unavailable
The design rule: **a failing source degrades its own tools and nothing else.**
Breakers, rate budgets and clients are per adapter. There is no global state
that one sick source can poison.
### Error taxonomy
Every failure — an HTTP status, an httpx exception, a GraphQL `errors` array —
is normalised into one typed error before it leaves the adapter (`errors.py`).
The tool layer never branches on status codes.
| Error | Retryable | Trips breaker | Raised for |
|---|:--:|:--:|---|
| `auth_failed` | ❌ | ❌ | 401, or 403 with quota remaining |
| `rate_limited` | ✅ | ✅ | 429, or GitHub's 403-with-`remaining: 0` |
| `unavailable` | ✅ | ✅ | 5xx, DNS failure, connection reset, TLS error |
| `timeout` | ✅ | ✅ | exceeded `HTTP_TIMEOUT_SECONDS` |
| `not_found` | ❌ | ❌ | 404 / Notion `object_not_found` |
| `bad_request` | ❌ | ❌ | other 4xx, Linear GraphQL errors |
| `circuit_open` | ✅ | — | local refusal; the source is not called |
**Non-retryable errors deliberately do not trip the breaker.** A bad token or a
typo'd repo name is not evidence that GitHub is down. Counting them would open
the circuit on a typo and take out a healthy source.
### Retry and backoff
Retryable errors are retried up to `MAX_RETRIES` with exponential backoff and
**full jitter** — uniform over `[0, min(8s, 0.5 · 2ⁿ))`. Full jitter rather than
fixed backoff because several tools hitting the same rate-limited source would
otherwise collide again on every retry wave. When the server sends
`Retry-After`, that number wins over our guess. Both delta-seconds and
HTTP-date forms are parsed.
### Circuit breaker
Per source, three states:
```
closed ──(N consecutive retryable failures)──▶ open
▲ │
│ (cooldown elapses)
│ ▼
└────────(probe succeeds)──────────────── half_open ──(probe fails)──▶ open
```
`half_open` admits exactly one probe; concurrent callers are refused locally
rather than all stampeding a recovering source. While open, calls fail in
microseconds without touching the network — which is the point: one dead source
must not consume the latency budget of a request that also touches two healthy
ones.
### Per-source failure behaviour
| Scenario | What the server does | What the model sees |
|---|---|---|
| GitHub primary rate limit (403, `remaining: 0`) | reads reset timestamp, sleeps that long, retries | `rate_limited` + exact `retry_after_seconds` |
| GitHub search rate limit (30/min, separate pool) | same, but only search tools are affected — core quota is untouched | `rate_limited`, other GitHub tools keep working |
| GitHub 404 from a fine-grained token's blind spot | no retry | `not_found` |
| Notion 429 (~3 req/s) | honours `Retry-After` | `rate_limited` + `retry_after_seconds` |
| Notion page not shared with the integration | no retry | `not_found` with a message naming the sharing fix |
| Notion 5xx | retried with jittered backoff, breaker counts it | `unavailable` |
| Linear GraphQL `errors` array on HTTP 200 | mapped to `auth_failed` / `rate_limited` / `bad_request` by `extensions.code` | the correct typed error, not a false success |
| Linear API key sent with a `Bearer` prefix | no retry | `auth_failed` |
| Any source down repeatedly | breaker opens; calls refused locally for `BREAKER_RESET_SECONDS` | `circuit_open` + seconds until the next probe |
| Any source not configured at all | its tools are hidden from `tools/list` entirely | tool does not exist; `system_health` says `not_configured` |
| Unexpected exception inside a tool | caught in `_guard` | `internal_error`; **the stdio transport stays up** |
That last row matters more than it looks. An unhandled exception in an MCP
tool can take down the transport, which kills every other source too. `_guard`
wraps every source-touching tool for exactly that reason.
### Observability
`system_health` probes all sources concurrently and returns, per source:
reachable, circuit state, consecutive failures, success/failure/locally-rejected
counts, rate budget, and last error kind. Where a source publishes no quota
headers — Notion — it reports `null` rather than inventing a number.
```json
{
"healthy": ["github", "linear"],
"degraded": ["notion"],
"not_configured": [],
"server_usable": true
}
```
---
## Setup
```bash
git clone <this repo> && cd mcp-multisource
make setup
cp .env.example .env # then fill it in
make test # offline: access control, breaker, degradation
make e2e # protocol: real stdio, fake creds, no secrets needed
make verify # live: hits all three real APIs
```
Getting credentials:
- **GitHub** — Settings → Developer settings → Personal access tokens →
Fine-grained. Grant `Contents: read`, `Issues: read+write`,
`Metadata: read`, `Pull requests: read`.
- **Notion** — [notion.so/my-integrations](https://www.notion.so/my-integrations)
→ new internal integration → copy the secret. **Then share at least one page
with it**, or it sees nothing.
- **Linear** — Settings → API → Personal API keys.
### Register with an MCP client
```json
{
"mcpServers": {
"multisource": {
"command": "/absolute/path/to/mcp-multisource/.venv/bin/python",
"args": ["-m", "mcp_multisource"],
"cwd": "/absolute/path/to/mcp-multisource"
}
}
}
```
`.env` is read relative to the project root, so `cwd` matters.
---
## Verification
Three suites, testing different things.
**`make test` — offline, no secrets.** 39 tests using `httpx.MockTransport`, so
real adapter code runs against synthetic responses with no network. Covers the
capability matrix, scoping, the list-filter-versus-boundary distinction, every
row of the error taxonomy above, breaker state transitions, retry semantics,
and cross-source isolation.
**`make e2e` — protocol, no secrets.** Everything else tests Python objects.
This launches the server over real stdio and drives it with a real MCP client,
using deliberately invalid credentials — so it checks the handshake, the wire
format, and degradation in one pass. Observed output:
```
Role-based tool visibility
PASS viewer sees no write tools [11 tools]
PASS viewer sees no admin tools
PASS admin sees every tool [16 tools]
PASS capabilities differ
Enforcement is independent of the list filter
PASS hidden tool call is refused [access_denied]
PASS denial is marked non-retryable
Partial configuration
PASS unconfigured sources' tools are absent [9 tools]
PASS health names them as not_configured
Degradation with invalid credentials
PASS a failing tool returns a structured result, not an exception [auth_failed]
PASS the failure names its source
PASS cross-source tool still returns
PASS and reports what it could not reach [open_issues, open_pull_requests, tracked_work]
PASS marking itself incomplete
```
That fourth block is the brief's question answered concretely: with GitHub
returning 401 and the other two sources unconfigured, `release_readiness` still
returns `ok: true` with a populated `degraded` list rather than raising.
**`make verify` — live.** Authenticates against all three real APIs, runs a
real read per source, optionally (`--write`) creates real records, then
*deliberately breaks things*: invalid credentials per source asserting the
error taxonomy, and a repeated failure run asserting the breaker opens. Exits
nonzero on failure, so it works in CI.
---
## Layout
```
src/mcp_multisource/
server.py tool surface; every source call wrapped in _guard
middleware.py AccessControlMiddleware — the enforcement seam
access.py capabilities, roles, resource scoping
resilience.py circuit breaker, jittered backoff, retry loop
errors.py the typed error taxonomy
registry.py builds adapters from settings; concurrent health probes
config.py the only module that reads os.environ
sources/
base.py shared HTTP: timeouts, retry, breaker, status→error mapping
github.py Bearer PAT; x-ratelimit-*; 403-means-rate-limit quirk
notion.py Bearer + version header; sharing trap; no quota headers
linear.py bare key; GraphQL; 200-with-errors dialect
scripts/verify_sources.py live verification + fault injection
scripts/e2e_check.py protocol smoke test over real stdio
tests/ offline suite
```
---
## Known limits
- No pagination beyond the first page on any list tool. Fine for the queries
these tools serve; would need cursors for exhaustive sync.
- Notion block flattening covers the common spec/runbook shapes, not arbitrary
nesting or database properties.
- Roles come from an environment variable, i.e. one role per server process.
A multi-tenant deployment would take identity from the transport (the SDK
exposes `TokenVerifier` / `auth` for this) rather than from config; the
policy layer is already shaped for it — only the source of `role` changes.
- Health is probed on demand, not on a background schedule.
TDQS
Scored across 3 tools
Each tool addresses a completely distinct concern: release_readiness provides aggregated cross-source data, system_health reports on source connectivity/status, and access_policy describes permissions. There is no overlap or ambiguity between their purposes.
All tool names follow the same snake_case noun_noun pattern (release_readiness, system_health, access_policy), which is predictable and readable. No mixed conventions or vague verbs appear.
Three tools is a well-scoped count for a focused multisource status server. Each tool earns its place and the set feels neither bloated nor too thin.
The server covers the core lifecycle of reading cross-source release readiness, monitoring source health, and checking access. A minor gap is the lack of a tool to query a specific source in detail or perform any mutating action, but agents can work around this with release_readiness and system_health.