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
| What | Detail |
|---|---|
| A nima API key | Create 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 Intelligence | search_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 key | ANTHROPIC_API_KEY, for the model in these examples. |
| Python packages | langchain[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.
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.
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.
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.
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.
| Tool | Route | Answers |
|---|---|---|
ask_knowledge_graph | GET /v1/knowledge-graph/ask | text/markdown |
search_code | GET /v1/code-intel/search | JSON |
create_signal | POST /v1/signals | JSON, 201 |
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
| Symptom | Cause |
|---|---|
| The model sees 30 nima tools | A 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 message | A 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_intelligence | The workspace plan lacks Code Intelligence. The scope alone is not enough. |
search_code names the wrong lines | The index is at lastCommitSha on the default branch. Compare it with your commit; branch work is invisible until it merges. |
| An empty answer | No product entities found for: and an empty results are both successes. Absence of evidence, not evidence of absence. |
| A tool call times out | Each MCP tool call gets 60 seconds. |
| Duplicate signals | create_signal has no idempotency key. A retry files a second one. |
429 across several keys | Rate limits key on the client address, not the key. Keys behind one address share a bucket, about 100 tool calls a minute. |
LangChainBetaWarning on import | Expected. langchain.mcp is beta. |