Skip to main content
Glama

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


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

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:

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.

{
  "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 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 → 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 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.

Available Tools

3 tools
access_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

    A3.7/5.0

    Scored across 3 tools

    Disambiguation5/5

    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.

    Naming Consistency5/5

    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.

    Tool Count5/5

    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.

    Completeness4/5

    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

    ActivityStale
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      A
      quality
      A
      maintenance
      Search, 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.
      3
      996 npm
      12
      MIT
    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables 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 npm
      ISC
    • A
      license
      Not graded
      quality
      A
      maintenance
      Gives 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 npm
      MIT
    • A
      license
      Not graded
      quality
      A
      maintenance
      Enables 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 npm
      MIT