docs
Cookbooks

LangChain and LangGraph

How to load nima's MCP tools into a LangChain agent with a personal API key, bind one into a LangGraph node, and fall back to plain HTTP.

At the end of this page you have three Python files. The first connects to nima's MCP server with a personal API key and lists the tools. The second is a LangChain agent that calls search_code and ask_knowledge_graph before it proposes anything, and files a signal when it finds something worth tracking. The third binds one nima tool into a custom LangGraph StateGraph node. A last section does the same over plain HTTP, for when MCP is not wanted.

On for every workspace. A personal API key works over MCP and REST with no setup by an admin.

LangChain's MCP support moved. The standalone langchain-mcp-adapters package is archived and no longer maintained; its MultiServerMCPClient is replaced by MCPAdapter in the langchain.mcp namespace. LangChain marks langchain.mcp as beta: it needs langchain[mcp]>=1.4.0, importing it raises a LangChainBetaWarning once per process, and the API may change. This page uses langchain.mcp and shows the archived form in a tab for existing code.

Prerequisites for the nima LangChain agent

WhatDetail
A nima API keyCreate one in the app under Settings, then API keys. A new key has knowledge:read and signals:read ticked by default. Add signals:write only if the agent should file signals.
Code Intelligencesearch_code is declared under the code_intelligence plan feature. A workspace whose plan lacks it can get 403 INSUFFICIENT_PERMISSIONS with details.feature set to code_intelligence; handle it. ask_knowledge_graph has no plan feature.
An Anthropic keyANTHROPIC_API_KEY, for the model in these examples.
Python packageslangchain[mcp] (at least 1.4.0), langchain[anthropic], langgraph, and requests for the REST section.
pip install "langchain[mcp]" "langchain[anthropic]" langgraph requests

export NIMA_API_KEY="nima_…"
export ANTHROPIC_API_KEY="sk-…"

Keep the key in a secret store, never in a repository. See Authentication for expiry and rotation, and Scopes for what each scope unlocks.

Connect LangChain to nima's MCP server

nima's MCP server is Streamable HTTP at https://mcp.getnima.io/mcp. A headless client sends the key as Authorization: Bearer $NIMA_API_KEY.

MCPAdapter delegates auth to FastMCP. A bearer token goes on a fastmcp.Client as its auth argument, and that client is handed to the adapter. A string URL is reached over Streamable HTTP.

nima_connect.py
import asyncio
import os

from fastmcp.client import Client
from langchain.mcp import MCPAdapter

NIMA_MCP_URL = "https://mcp.getnima.io/mcp"


async def main() -> None:
    token = os.environ["NIMA_API_KEY"]
    # `auth` takes a bearer-token string; FastMCP sends it as
    # "Authorization: Bearer <token>" on every request.
    async with MCPAdapter(Client(NIMA_MCP_URL, auth=token)) as adapter:
        tools = await adapter.list_tools()
        for tool in sorted(tools, key=lambda t: t.name):
            print(tool.name)


if __name__ == "__main__":
    asyncio.run(main())

Which tools LangChain lists for a nima key

A key with the default scopes still lists 30 tools, not the nine its two scopes can call. tools/list shows every tool behind the eight consent scopes (knowledge:read, signals:read, sources:read, documents:read, signals:write, subscriptions:read, subscriptions:write, inbox:read), whether or not the key holds the scope. Tools behind operations:write or integrations:write appear only when the key holds that scope. The MCP overview links the tool reference for each area.

Filter by name, as the agent examples below do, so the model sees only the tools you intend it to use:

READ_TOOLS = {"ask_knowledge_graph", "search_code", "search_signals", "get_signal"}
tools = [t for t in await adapter.list_tools() if t.name in READ_TOOLS]

Being listed does not mean callable. A call to a tool whose scope the key lacks fails with 403 (insufficient_scope) on the MCP request itself, which LangChain treats as a transport failure and raises, rather than a tool result the model can read. Create a key with the scope the tool needs. Errors the tool itself reports come back as a tool result with isError.

Ask nima before a LangChain agent acts

The agent gets the read tools only and a system prompt that makes it look before it proposes. ask_knowledge_graph answers why and who; search_code answers where.

nima_agent.py
import asyncio
import os

from fastmcp.client import Client
from langchain.agents import create_agent
from langchain.mcp import MCPAdapter
from langchain.messages import ToolMessage

NIMA_MCP_URL = "https://mcp.getnima.io/mcp"
READ_TOOLS = {"ask_knowledge_graph", "search_code", "search_signals", "get_signal"}

SYSTEM_PROMPT = """Before you propose any change, ask nima.
1. Call search_code with the feature or symbol. For each repository, report
   lastCommitSha and say so when it is behind the commit you are working on.
2. Call ask_knowledge_graph with the feature name, to learn who asked for it
   and what was decided.
3. Quote what the tools returned. When they return nothing, say nothing was
   found. Never say nothing exists."""


async def main() -> None:
    token = os.environ["NIMA_API_KEY"]
    task = "I want to raise the CSV import row limit. What should I know first?"

    async with MCPAdapter(Client(NIMA_MCP_URL, auth=token)) as adapter:
        tools = [t for t in await adapter.list_tools() if t.name in READ_TOOLS]
        agent = create_agent(
            model="anthropic:claude-opus-5-5",
            tools=tools,
            system_prompt=SYSTEM_PROMPT,
        )
        result = await agent.ainvoke(
            {"messages": [{"role": "user", "content": task}]}
        )

    for message in result["messages"]:
        for call in getattr(message, "tool_calls", None) or []:
            print(f"call {call['name']} {call['args']}")
        if isinstance(message, ToolMessage):
            print(f"  -> {message.text[:400]}")
    print(result["messages"][-1].text)


if __name__ == "__main__":
    asyncio.run(main())

The model line is the only thing to change for another provider: create_agent takes any "provider:model" string LangChain supports, or an initialized chat model.

A run makes calls like these. The results below are trimmed.

call search_code {'q': 'where is the CSV import row limit enforced', 'limit': 5}
call ask_knowledge_graph {'q': 'why does CSV import have a row limit'}

search_code returns JSON: every repository with the commit its index was built from, and ranked files with their symbols and line ranges. Source code is never returned.

{
  "query": "where is the CSV import row limit enforced",
  "repositories": [
    {
      "id": "6a1e3f90-8b24-4c7d-a05e-2f9d71c3b846",
      "repoFullName": "acme/ledger",
      "indexStatus": "ready",
      "lastCommitSha": "e93b0c417d2a58f6c10b4e7d9a3f25c86b0d1e74",
      "lastSyncedAt": "2026-09-20T14:02:51.118Z",
      "indexWarnings": []
    }
  ],
  "droppedRepositories": [],
  "results": [
    {
      "repositoryId": "6a1e3f90-8b24-4c7d-a05e-2f9d71c3b846",
      "repoFullName": "acme/ledger",
      "filePath": "src/import/row-limit.ts",
      "score": 0.3871,
      "summary": "Stops a CSV import once it passes the workspace row ceiling and reports how many rows were skipped.",
      "matchedChannels": ["keyword", "symbol", "vector"],
      "match": "semantic",
      "symbols": [
        {
          "name": "enforceRowLimit",
          "kind": "function",
          "signature": "enforceRowLimit(rows: number, plan: PlanSlug): void",
          "lineStart": 12,
          "lineEnd": 39
        }
      ]
    }
  ]
}

Each hit's match is exact, semantic or none; none on a path- or symbol-shaped query means no channel confirmed that the thing exists.

ask_knowledge_graph returns markdown by default, not JSON: matching entities, each with a modality label, their sourced facts, relationships and any unresolved contradictions. Pass mode="agent" for compact JSON instead.

  Knowledge graph results for: "why does CSV import have a row limit"

  ### CSV import (feature) [implemented] · id: 3c5e91a7-0b42-4d68-a1f3-7e2d90b4c816
  References: 9 | Similarity: 0.742
  Summary: Bulk import of ledger rows from CSV, capped per workspace.
  Facts:
    - row_limit: 40000 (source: SPEC:1c4e8a27-93b5-4f06-bd21-7e5a0c9f3d18, learned 2026-05-02)
    - reason: imports above the cap timed out at the gateway (source: SLACK:5d7f2b91-0e46-4a38-9c1b-8f3e6a2d4c05, learned 2026-04-28)
  Relations:
    - ← Northwind (customer) requested this — "asked for 100k-row imports on the renewal call"

When nothing scores high enough, ask_knowledge_graph still succeeds and the body starts No product entities found for:. An empty results from search_code is also a success. Neither means the thing is not there.

File a signal from a LangChain agent

create_signal files a new signal into the pipeline. The key needs signals:write; the tool is listed either way, and a call without the scope fails with 403. It takes content (up to 10,000 characters), sourceLabel, stakeholders and productAreas.

nima_file_signal.py
import asyncio
import os

from fastmcp.client import Client
from langchain.agents import create_agent
from langchain.mcp import MCPAdapter

NIMA_MCP_URL = "https://mcp.getnima.io/mcp"
TOOLS = {"ask_knowledge_graph", "search_code", "search_signals", "create_signal"}

SYSTEM_PROMPT = """You triage bugs found while coding.
Call search_signals first. If a signal already describes the problem, report
its id and stop. Otherwise call create_signal once, with the problem in plain
words as content, sourceLabel "coding agent", and the affected product areas."""


async def main() -> None:
    token = os.environ["NIMA_API_KEY"]
    finding = (
        "enforceRowLimit in src/import/row-limit.ts counts the header row, "
        "so a file with exactly 40000 data rows is rejected."
    )

    async with MCPAdapter(Client(NIMA_MCP_URL, auth=token)) as adapter:
        tools = [t for t in await adapter.list_tools() if t.name in TOOLS]
        agent = create_agent(
            model="anthropic:claude-opus-5-5",
            tools=tools,
            system_prompt=SYSTEM_PROMPT,
        )
        result = await agent.ainvoke(
            {"messages": [{"role": "user", "content": finding}]}
        )

    print(result["messages"][-1].text)


if __name__ == "__main__":
    asyncio.run(main())

The call the agent makes, and the created signal, trimmed:

call create_signal {'content': 'CSV import rejects files with exactly 40000 data rows: enforceRowLimit counts the header row.', 'sourceLabel': 'coding agent', 'productAreas': ['import']}
{
  "id": "f28b6d14-5c93-4e70-a1d8-3b7e0c52a9f6",
  "source": "manual",
  "sourceChannel": "coding agent",
  "status": "pending",
  "classification": null,
  "impact": null,
  "extractedProductArea": ["import"],
  "priorityScore": 0,
  "createdAt": "2026-09-23T08:41:17.260Z"
}

The signal comes back pending, with classification and impact still null. nima fills them asynchronously. There is no idempotency key, so a retried call files a second signal.

Bind one nima tool into a custom LangGraph graph

When a prebuilt agent is more than you need, bind a single nima tool into your own StateGraph. This graph can only ask the knowledge graph: the model is bound to ask_knowledge_graph alone, and ToolNode runs it.

nima_graph.py
import asyncio
import os

from fastmcp.client import Client
from langchain.chat_models import init_chat_model
from langchain.mcp import MCPAdapter
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition

NIMA_MCP_URL = "https://mcp.getnima.io/mcp"


async def main() -> None:
    token = os.environ["NIMA_API_KEY"]
    model = init_chat_model("anthropic:claude-opus-5-5")

    async with MCPAdapter(Client(NIMA_MCP_URL, auth=token)) as adapter:
        by_name = {t.name: t for t in await adapter.list_tools()}
        if "ask_knowledge_graph" not in by_name:
            raise SystemExit("ask_knowledge_graph is not listed.")
        ask_kg = by_name["ask_knowledge_graph"]
        model_with_kg = model.bind_tools([ask_kg])

        async def call_model(state: MessagesState) -> dict:
            response = await model_with_kg.ainvoke(state["messages"])
            return {"messages": response}

        builder = StateGraph(MessagesState)
        builder.add_node(call_model)
        builder.add_node(ToolNode([ask_kg]))
        builder.add_edge(START, "call_model")
        builder.add_conditional_edges("call_model", tools_condition)
        builder.add_edge("tools", "call_model")
        graph = builder.compile()

        result = await graph.ainvoke(
            {
                "messages": [
                    {
                        "role": "user",
                        "content": "Who asked for larger CSV imports, and what did they say?",
                    }
                ]
            }
        )

    print(result["messages"][-1].text)


if __name__ == "__main__":
    asyncio.run(main())

tools_condition routes to the node named tools when the model asked for a tool, and ends the run when it did not. Swap init_chat_model's argument to change provider; nothing else moves.

Call nima over REST from LangChain instead of MCP

Every tool is one HTTP route on https://api.getnima.io, with the same key in the same header. Wrapping the two read routes as LangChain tools gives the agent the same behaviour without an MCP client.

ToolRouteAnswers
ask_knowledge_graphGET /v1/knowledge-graph/asktext/markdown
search_codeGET /v1/code-intel/searchJSON
create_signalPOST /v1/signalsJSON, 201
nima_rest_agent.py
import asyncio
import os

import requests
from langchain.agents import create_agent
from langchain.tools import tool

NIMA_API = "https://api.getnima.io"


def _headers() -> dict:
    return {"Authorization": f"Bearer {os.environ['NIMA_API_KEY']}"}


@tool
def ask_knowledge_graph(q: str, limit: int = 8) -> str:
    """Ask nima's knowledge graph why something was built and who asked for it.

    Args:
        q: The question in plain English, 2 to 500 characters.
        limit: Entities to return, 1 to 20.
    """
    response = requests.get(
        f"{NIMA_API}/v1/knowledge-graph/ask",
        headers=_headers(),
        params={"q": q, "limit": limit},
        timeout=60,
    )
    response.raise_for_status()
    return response.text  # markdown, not JSON


@tool
def search_code(q: str, limit: int = 8) -> str:
    """Find where something is implemented: ranked files, symbols, line ranges.

    Args:
        q: What to find, in plain English or as an identifier.
        limit: File hits to return, at most 20.
    """
    response = requests.get(
        f"{NIMA_API}/v1/code-intel/search",
        headers=_headers(),
        params={"q": q, "limit": limit},
        timeout=60,
    )
    response.raise_for_status()
    return response.text  # JSON, passed to the model as text


async def main() -> None:
    agent = create_agent(
        model="anthropic:claude-opus-5-5",
        tools=[ask_knowledge_graph, search_code],
        system_prompt=(
            "Before you propose any change, call search_code and "
            "ask_knowledge_graph, and quote what they returned."
        ),
    )
    result = await agent.ainvoke(
        {
            "messages": [
                {
                    "role": "user",
                    "content": "I want to raise the CSV import row limit. What should I know first?",
                }
            ]
        }
    )
    print(result["messages"][-1].text)


if __name__ == "__main__":
    asyncio.run(main())

Filing a signal over HTTP is one POST, and needs signals:write:

import os

import requests

response = requests.post(
    "https://api.getnima.io/v1/signals",
    headers={"Authorization": f"Bearer {os.environ['NIMA_API_KEY']}"},
    json={
        "content": "CSV import rejects files with exactly 40000 data rows: enforceRowLimit counts the header row.",
        "sourceLabel": "coding agent",
        "productAreas": ["import"],
    },
    timeout=60,
)
response.raise_for_status()
signal = response.json()

A body that fails validation answers 400 with error set to Bad Request and no per-field messages, so check the field rules in Signals before sending.

LangChain gotchas with nima

SymptomCause
The model sees 30 nima toolsA default key lists every consent-scope tool. Filter list_tools() by name.
A tool is missing from list_tools()It sits behind operations:write or integrations:write, which the key lacks. Those come only from Slack /nima mcp-key.
A tool call raises instead of returning an error messageA missing scope is a 403 insufficient_scope on the MCP request, which LangChain raises as a transport failure. Errors the server reports (a 429, a plan-gate 403, a bad argument) come back as a tool result with isError; how your agent surfaces them depends on its tool-error handling.
search_code fails with 403 and details.feature code_intelligenceThe workspace plan lacks Code Intelligence. The scope alone is not enough.
search_code names the wrong linesThe index is at lastCommitSha on the default branch. Compare it with your commit; branch work is invisible until it merges.
An empty answerNo product entities found for: and an empty results are both successes. Absence of evidence, not evidence of absence.
A tool call times outEach MCP tool call gets 60 seconds.
Duplicate signalscreate_signal has no idempotency key. A retry files a second one.
429 across several keysRate limits key on the client address, not the key. Keys behind one address share a bucket, about 100 tool calls a minute.
LangChainBetaWarning on importExpected. langchain.mcp is beta.

On this page