Skip to content

Advisor MCP endpoint

Polaris exposes the Advisor (the read/serve agent) as a remote MCP server, so any MCP client — Claude, an agent framework, your own tooling — can query a published brain with grounded, cite-then-verified answers. It's the companion to the OpenAI-compatible endpoint: same Advisor, same API keys, a second transport.

Endpoint

https://<host>/v1/mcp        # streamable-http MCP
  • Brain is selected by your key. Each API key is bound to one brain, so a single URL serves the brain your key belongs to — no path parameter to manage.
  • <host> is wherever the capability gateway is published. Locally that's localhost:8088; on a deployed host it's the public origin — the reference deployment fronts the gateway with nginx behind a Tailscale Funnel, so /v1/mcp is served at the tailnet hostname. See Edge & Tailscale.
  • Read-only. The endpoint exposes only the Advisor's read tools (below); it cannot modify the brain.
  • This is distinct from the local operator MCP server used by Polaris developers — see Operator (stdio) server.

Auth — API keys

Send your key as a bearer token:

Authorization: Bearer sk-polaris-…

The credential is a long-lived Polaris API key (sk-polaris-…) carrying the advisor:read scope — the same key that works on /chat/completions. A Polaris access JWT for a tenant user also works (brain taken from the token). Keys are advisor-only; presenting one to any non-advisor surface is rejected.

Error codes

Auth failures reject the MCP connection with an HTTP status; the distinct reasons are:

HTTP Meaning
401 Missing, unknown (invalid_api_key), or revoked (revoked_api_key) credential.
403 Valid key but it lacks the advisor:read scope (insufficient_scope).

Revocation takes effect on the next request — a revoked key stops connecting immediately.

Tools

Tool Input Returns
answer query: string, topic_ids?: string[] A grounded, cite-then-verified answer envelope: result, citations, confidence, guardrail, brain_version. The Advisor's core capability.
retrieve query: string, topic_ids?: string[] The scope-filtered source passages that ground an answer — citations, no synthesis. Use when your agent wants the raw evidence.
topic_cards The subjects this brain knows it can be asked about. Each card carries an id to pass back as topic_ids.
read_wiki entity?: string Browse the published brain as human-readable articles. Omit entity for the index (categories + titles); pass a title for that one article.

Scoping a question to a subject

A question rarely names the citations it needs. "What notices do we owe when we turn someone down?" never says § 1002.9 or 15 U.S.C. § 1681m(a) — and similarity search cannot bridge that gap, because a provision's text does not repeat its own citation. Asking the index for 12 CFR 1026.4 returns citation fragments, not the rule.

A topic card closes it. A curator names a kind of question and the provisions its answers turn on; passing the card's id supplies those provisions' verbatim text by exact key — the one retrieval path incapable of a near miss.

Two calls. List the catalog, then scope:

cards = (await client.call_tool("topic_cards", {})).structured_content
for c in cards["topics"]:
    print(c["id"], "—", c["label"])
    print("   ", c["covers"])                     # the fact patterns, in the words people ask in

result = await client.call_tool("answer", {
    "query": "We declined an applicant because their credit score was below our threshold. "
             "What notices do we owe, and how specific must the reasons be?",
    "topic_ids": ["denying-an-application-the-notices-we-owe"],
})

Each card returns:

Field Meaning
id Pass this back as an entry in topic_ids.
label Short name — what a reader picks from a list.
covers The fact patterns it covers, written in the asker's vocabulary rather than the law's. Match your question against this, not against the label.
anchors The citation keys this subject's answers turn on.
unsupportable_anchors How many of those the corpus cannot supply. A card carrying some still works — it delivers less than it claims, and is a weaker bet than a clean one.

Rules worth knowing:

  • Optional, and omitting it changes nothing. An unscoped answer retrieves exactly as it always has. Scoping is additive to your call, never a precondition.
  • At most two cards. A question spanning more than two subjects is usually two questions.
  • An unknown id is ignored, not an error. A stale id from a cached catalog degrades to an unscoped answer rather than failing the question.
  • An empty catalog is normal. topic_cards returns no topics for a brain whose curator has not authored any, and answer behaves exactly as it did before.
  • Scope is recorded. The audit row for a scoped answer carries the card ids that routed it, so a served answer can be accounted for after the fact.

Match on covers, not on label. Labels are short by design; covers is the sentence written to be matched against a real question. An agent choosing a card should read it.

The citation model

Every answer/retrieve citation is a navigable entry point back into the knowledge base, not just a label. Each entry in citations[]:

Field Meaning
source_id The cited source (a document title or entity name).
kind passage (verbatim source chunk — strongest evidence) or entity (graph/entity summary — contextual).
quote The verbatim entailing span from the source (the sentence that supports the claim, not a prefix).
locator A precise in-document locator (e.g. §1026.2) when the extractor captured one.
score Retrieval relevance of the source, 0–1.
verified / verification verification.methodentailment (LLM judge) / lexical / lexical_fallback (judge errored → unconfirmed) / unverified, plus a score.
provenance Hard provenance: source_doc_pk, doc_title, origin_url (the source document's root URL), jurisdiction.
wiki Deep links into the published Wiki (Reader) — [{entity, ref}] where ref = GET /v1/brains/{brain_id}/wiki/{entity}. Follow these (or call read_wiki(entity)) to pull the full article.
concept_refs Entity names for graph traversal from the answer into the knowledge base (feed to read_wiki / retrieve).

The envelope also carries confidence (0–1, weighted by per-citation verification × retrieval strength) and guardrail (pass / disclaim / block) — an agent should treat a disclaim / low-confidence answer as "not grounded," even if prose is returned.

Two channels per result — rendered text and structured citations

answer and retrieve return both MCP content channels so each consumer gets the right shape:

  • content (text) — the answer followed by a rendered ## Sources block: one numbered entry per citation with the verbatim quote, [source](origin_url), and [wiki: entity](ref) deep-links. Labels are cleaned (single-line) and de-duplicated (repeated citations of the same span are collapsed; distinct spans of one document are kept). Interactive harnesses hand this text to a summarizing model, which routinely drops raw JSON — pre-rendering the sources means even a small local model can carry the provenance into its prose.
  • structuredContent — the full envelope (result, enriched citations[] with every field above, confidence, guardrail, brain_version). Programmatic consumers read this directly — nothing is lost to summarization. (The structured list is the raw pipeline output — it is not de-duped and labels are un-sanitized; do that yourself if you render it.)

How an agent should consume citations

  • Programmatic integrations (recommended): read structuredContent, ignore the prose. Iterate citations[] and use the fields directly — origin_url to link the evidence document, quote to quote it, wiki[].ref / read_wiki(entity) to expand a concept, concept_refs to traverse the graph, verification/score to weight or filter. Gate on confidence + guardrail.
  • Interactive / chat harnesses (a model summarizes the tool result): the model decides what to show. MCP hands the model both channels, but a summarizing model — especially a small local one — often rewrites the answer and discards the citations. This is a client/model behavior, not a Polaris omission (the sources are in the result). Fix it by instructing the agent to reproduce the ## Sources block verbatim (see the OpenCode guide for a ready-to-use rule), and prefer a stronger model when citations matter.

"Good answer, no citations" is almost always the model, not the endpoint. Verify with a raw MCP tools/call (below) — if the result carries citations[] / a ## Sources block, the endpoint is fine and the consuming model is dropping them.

Connect a client

Claude Code / Claude Desktop — add it as a remote HTTP MCP server:

claude mcp add --transport http polaris-advisor https://<host>/v1/mcp \
  --header "Authorization: Bearer sk-polaris-…"

Then /mcp lists polaris-advisor with its tools; ask a question in natural language and the client calls answer. To scope a question, ask the client to call topic_cards first and pass the matching id — most harnesses will do this unprompted once they can see the catalog.

Any MCP SDK (e.g. Python fastmcp):

from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport

transport = StreamableHttpTransport(
    url="https://<host>/v1/mcp",
    headers={"Authorization": "Bearer sk-polaris-…"},
)
async with Client(transport) as client:
    tools = await client.list_tools()             # answer, retrieve, topic_cards, read_wiki
    result = await client.call_tool("answer", {"query": "Do I need a CA lender license?"})
    # …or scope it to a subject — see "Scoping a question to a subject" above:
    #   await client.call_tool("answer", {"query": …, "topic_ids": ["some-card-id"]})

    env = result.structured_content                         # the full envelope — read this, not prose
    print(env["result"], "\n")
    if env["guardrail"]["status"] != "pass" or env["confidence"] < 0.5:
        print("⚠ not well-grounded")                        # gate on grounding before trusting it
    for c in env["citations"]:
        prov = c.get("provenance") or {}
        print(f"- [{c['kind']}] {c['source_id']}  ({prov.get('origin_url')})")
        print(f"    “{c['quote']}”")
        for w in c.get("wiki", []):
            print(f"    wiki: {w['entity']}{w['ref']}")  # or: await client.call_tool('read_wiki', {'entity': w['entity']})

Managing keys

Keys are created and revoked exactly as for the completions endpoint — from the SPA (Settings → API keys, raw shown once), over REST (POST/GET/DELETE /v1/brains/{brain_id}/api-keys), or via the CLI (bb apikey mint|list|revoke). See Managing keys. One key works on both MCP and completions.

Operator (stdio) server

Polaris developers drive the full tool surface (Librarian, Scout, corpus health, the crawler, certify/publish, …) from a local stdio MCP server registered in Claude Code — not the customer surface above. That flow is documented in the repo cookbook: cookbooks/MCP_USER_GUIDE.md. It is local by design; never expose it remotely — it writes corpora.

Identity (DEE-97)

The operator server used to be unauthenticated: brain_id was a parameter defaulting to the deployment's brain, and actor was free text defaulting to "curator". That is fine for a person at a terminal running one command, and no model at all once tools run unattended — a mistyped brain writes another tenant's corpus, and every audit row says whatever string the caller passed.

There is no bearer header to authenticate against here: this is stdio, not HTTP, so unlike the Advisor endpoint above there is no request boundary to attach auth to. The credential therefore arrives at process launch, and is resolved once against the same api_key table:

bb apikey mint --operator --brain-id calliope --actor you@example.com --label "claude-code"
export POLARIS_MCP_API_KEY='sk-polaris-…'

With a key resolved, the session acts as that key's actor. A caller that passes a different actor is refused rather than silently overwritten — quietly rewriting it would let the caller believe it had attributed work to someone else. The key's brain becomes the default, and every audit row carries authenticated: true and the key id.

Scope Grants
mcp:invoke Use the operator toolset at all. Required — an advisor:read key does not authenticate here.
mcp:write Tools that mutate a corpus (autonomy class A2/A3). Omit it with --read-only.
mcp:any-brain Act on a brain other than the key's own. Without it, naming another brain is refused.

Without a key the server still runs, under weaker guards: writes require an explicit brain_id and a named non-placeholder actor. But the actor is self-asserted — nothing verified it — so every write result and audit row records authenticated: false rather than omitting the field. A missing field reads as "not recorded", and a silent row gets taken for a verified one.

Call mcp_session before anything that writes to see which mode you are in.

The credential is resolved once per process, so a key revoked mid-session keeps working until the server restarts — a deliberate trade against a DB round-trip per tool call, bounded by how long a stdio session lives.

Autonomy classes

Tools added from DEE-97 on declare what they may do without a human, enforced by app/mcp/_autonomy.py. The governing rule: an MCP default may never be more permissive than what the UI shows a person. Where the SPA renders a choice, the tool must refuse, require an explicit parameter, or disclose the default it applied.

Class Meaning Gate
A0 Read — no cost, no mutation none
A1 Metered — spends credits, quota or model calls required ceiling; spend always returned
A2 Reversible write — mutates the draft; git-revertible identity; dry_run where it applies
A3 Judgement write — encodes a curator's semantic claim a named approved_by
A4 Irreversible — publishes or leaves the system never automated

The full tool inventory lives in the app/mcp/server.py module docstring, and a test keeps it in step with what is actually registered.

Limits (current)

  • Read-only: answer, retrieve, topic_cards, read_wiki only.
  • One brain per key (the key's brain); no cross-brain access.
  • Requires a published version of the brain for grounded answers (read_wiki reports if none).