mcp-multisource
Provides tools for reading GitHub repositories, issues, pull requests, and code search, as well as commenting on issues.
Provides tools for listing Linear teams and issues, creating issues, and commenting on issues.
Provides tools for searching Notion, retrieving page text, and creating pages in a Notion workspace.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-multisource Review the checkout rewrite spec and file Linear issues for gaps without PRs."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 GraphQLWhat 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 | read |
| GitHub | read |
| GitHub | read |
| GitHub | read |
| GitHub | write |
| Notion | read |
| Notion | read |
| Notion | write |
| Linear | read |
| Linear | read |
| Linear | write |
| Linear | write |
| all three | read |
| — | read |
| — | read |
| — | admin |
Related MCP server: Nexus
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 |
| Also requires |
Notion |
| The version header is mandatory. Notion pins behaviour to a date string rather than negotiating; omit it and every call 400s. |
Linear |
| No |
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 |
| ✅ | ❌ | ❌ |
| ✅ | ✅ | ❌ |
| ✅ | ✅ | ✅ |
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/listit filters the advertised tools. This is prompt hygiene — the model never burns a turn on a tool it cannot call.On
tools/callit 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.pyasserts 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:
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 |
| ❌ | ❌ | 401, or 403 with quota remaining |
| ✅ | ✅ | 429, or GitHub's 403-with- |
| ✅ | ✅ | 5xx, DNS failure, connection reset, TLS error |
| ✅ | ✅ | exceeded |
| ❌ | ❌ | 404 / Notion |
| ❌ | ❌ | other 4xx, Linear GraphQL errors |
| ✅ | — | 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)──▶ openhalf_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, | reads reset timestamp, sleeps that long, retries |
|
GitHub search rate limit (30/min, separate pool) | same, but only search tools are affected — core quota is untouched |
|
GitHub 404 from a fine-grained token's blind spot | no retry |
|
Notion 429 (~3 req/s) | honours |
|
Notion page not shared with the integration | no retry |
|
Notion 5xx | retried with jittered backoff, breaker counts it |
|
Linear GraphQL | mapped to | the correct typed error, not a false success |
Linear API key sent with a | no retry |
|
Any source down repeatedly | breaker opens; calls refused locally for |
|
Any source not configured at all | its tools are hidden from | tool does not exist; |
Unexpected exception inside a tool | caught in |
|
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.
{
"healthy": ["github", "linear"],
"degraded": ["notion"],
"not_configured": [],
"server_usable": true
}Setup
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 APIsGetting 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 → 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
{
"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 incompleteThat 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 suiteKnown 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/authfor this) rather than from config; the policy layer is already shaped for it — only the source ofrolechanges.Health is probed on demand, not on a background schedule.
Available Tools
3 toolsaccess_policyA
Show this session's role, capabilities and resource scopes.
Inputs: no named parameters. Returns: object
release_readinessB
Cross-source snapshot: open GitHub PRs/issues for a repo, matching Notion specs, and Linear tracked work. Returns partial results with a degraded list when a source is unavailable.
Inputs: repo (required), team_key, spec_query. Returns: object
system_healthC
Per-source health: reachable, circuit state, rate budget, last error.
Inputs: probe. Returns: object
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
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.
Maintenance
Related MCP Connectors
Context and control for any AI. Agent identity, scoped access, and proof across tools.
Query your team's drift, vulnerability, and upgrade data from any AI assistant. OAuth 2.1, 51 tools.
One gateway, every app: Gmail, GitHub, Notion and more. Connect once, call any tool. Managed auth.
One searchable history across every AI coding tool, with secret scanning and a shared task board.
Related MCP Servers
- AlicenseAqualityAmaintenanceSearch, document and execute authenticated API calls across all your apps (Gmail, Slack, Stripe, Notion, GitHub, and more) through 4 universal tools whose context footprint stays constant no matter how many connections you add. Hosted remote server with OAuth at https://mcp.withone.ai/mcp, or run locally via npx @withone/mcp.3996 npm12MIT
- AlicenseNot gradedqualityDmaintenanceEnables models to query enterprise systems—structured data, live GitHub REST API, and unstructured document embeddings—through a single, per-caller scoped MCP tool surface with task-shaped tools and recovery-aware errors.90 npmISC
- AlicenseNot gradedqualityAmaintenanceGives AI agents and coding assistants read/write access to Notion — creating and editing pages, querying databases, appending blocks, applying templates, commenting, and uploading files — through just three context-efficient tools. It authenticates with a single token so it works headless in automation, CI, and self-hosted deployments, with batched mutations, idempotency keys, and retry handling built in.5,773 npmMIT
- AlicenseNot gradedqualityAmaintenanceEnables AI agents and MCP clients to read and write a Notion workspace through a single token, covering pages, databases, blocks, comments, templates, and file uploads. It keeps the model's context small by exposing three tools and fetching operation schemas on demand, while supporting batched, idempotent mutations and headless use in CI or self-hosted deployments.5,773 npmMIT