Skip to content

MCP Server Guide

When to use this: You want to query a little-loops project's state — issues, dependency health, session history — from an MCP-capable client such as Claude Code, Codex, or Claude Desktop. This guide covers installing, registering, verifying, and troubleshooting the ll-mcp stdio server. For the authoritative tool parameter and response schemas, see CLI Reference § ll-mcp — this guide deliberately does not restate them.

Contents


What ll-mcp Is

ll-mcp is an MCP server (stdio by default, streamable HTTP with --http) that exposes a little-loops project over the Model Context Protocol. It advertises three surfaces:

Surface What it gives a client
Tools (read) issues_query, issue_get, history_search, deps_check, capabilities, queue_list, queue_get, loop_list, skills_list
Tools (write) issue_capture, issue_set_status, issue_link, issue_append_log, queue_add, queue_remove, queue_requeue — dry-run by default, see below
Resources Issue files, .ll/ll-goals.md, and docs/**/*.md under an ll:// scheme; one interactive ui://issues/view MCP Apps resource (ENH-3306)
Prompts Every discovered SKILL.md, listed as an invocable MCP prompt
Tasks tasks/get / tasks/cancel — poll or stop an in-flight ll-loop run, see below

It is launched by a host, never by hand — it speaks JSON-RPC on stdin/stdout and prints nothing useful to a terminal. Each tool wraps a little_loops library call directly: no subprocess, no ll-* CLI shelling out, no orchestration.


Install

The MCP server lives behind an optional extra, because the mcp SDK is a heavyweight dependency that most little-loops users never need:

pip install "little-loops[mcp]"

Without the extra, ll-mcp exits 2 with ll-mcp requires themcpextra rather than an ImportError traceback. Because hosts usually swallow a server's stderr, this failure typically surfaces only as "server failed to start" in the client — run ll-mcp directly in a terminal to see the real message (it will hang waiting for JSON-RPC input if the extra is installed; Ctrl-D to exit).


Pointing the Server at a Project

ll-mcp resolves the project it serves in this order:

  1. --project-root /abs/path — an argument on the server command.
  2. LL_MCP_PROJECT_ROOT=/abs/path — the equivalent for hosts whose server config exposes an env block but not args.
  3. The process's current working directory — the fallback when neither is set.

This matters because MCP hosts vary in what cwd they spawn a server with. A client that launches its servers from $HOME starts ll-mcp against $HOME unless told otherwise, and every tool then answers truthfully about a project that does not exist there — issues_query returns [], deps_check reports a clean graph, resources/list is empty, history_search finds nothing. Setting the root explicitly is the reliable fix:

{
  "command": "ll-mcp",
  "args": ["--project-root", "/abs/path/to/project"]
}

Claude Code and Codex both launch project-scoped servers from the project root, so the ll-adapt-generated configs below need no root argument. For other clients, --project-root is the recommended form; a cwd field in the server config, or a shell wrapper ({"command": "sh", "args": ["-c", "cd /abs/path && exec ll-mcp"]}), also work if you prefer them.

When the root is wrong, the server says so. A resolved root with neither a .ll/ nor an .issues/ directory produces a warning on stderr at startup, naming the root it resolved. Because hosts commonly swallow a server's stderr, the same signal is also carried by the capabilities tool — the first thing worth calling when verifying a new registration:

"project_root": { "path": "/Users/you", "resolved": false }

resolved: false means the tool surface will answer empty about everything.

Binding the HTTP transport

The streamable HTTP transport (--http, or LL_MCP_TRANSPORT=http) binds 127.0.0.1:8765 by default. Override it with --host / --port, or persistently in .ll/ll-config.json; the flags win over the config block:

{
  "mcp": {
    "http": { "host": "127.0.0.1", "port": 9000 }
  }
}

No path here defaults to 0.0.0.0 — a non-loopback bind is always something you asked for. When you do ask for one, the server widens TransportSecuritySettings' allowed_hosts/allowed_origins to that host (the SDK auto-fills that allow-list only for loopback, and would otherwise reject every request's Host/Origin header). That is DNS-rebinding protection, not authentication — the HTTP transport ships with none, which is why mcp.transport_policy.http denies mutations and tasks by default regardless of what you bind to. See Guard 2.


Registering the Server

Claude Code

ll-adapt --host claude-code --apply

This merges an ll-mcp entry into .mcp.json at the project root, preserving any existing mcpServers content:

{
  "mcpServers": {
    "ll-mcp": {
      "command": "ll-mcp"
    }
  }
}

Codex

ll-adapt --host codex --apply

The same run that bridges skills, commands, and agent personas into Codex also merges an [mcp_servers.ll-mcp] table into Codex's global ~/.codex/config.toml (or $CODEX_HOME/config.toml) — Codex has no project-local MCP config read path, so this is the one emitter that writes outside the project directory:

[mcp_servers.ll-mcp]
command = "ll-mcp"
args = []

Claude Desktop and other MCP clients

No emitter exists for these yet — ll-adapt --host <other> skips the MCP artefact — so write the config by hand. Claude Desktop reads ~/Library/Application Support/Claude/claude_desktop_config.json on macOS:

{
  "mcpServers": {
    "ll-mcp": {
      "command": "/abs/path/to/venv/bin/ll-mcp",
      "args": ["--project-root", "/abs/path/to/project"]
    }
  }
}

Use an absolute path to the ll-mcp executable. A GUI-launched client does not inherit your shell's PATH, so a bare "ll-mcp" that works from a terminal will fail there with a spawn error. which ll-mcp gives you the path to paste.

One entry per project: a server instance serves exactly one root, so a second project means a second mcpServers key (ll-mcp-otherproject) with its own --project-root.


Verifying with mcp-call

mcp-call is a thin JSON-RPC client that ships with little-loops. It reads .mcp.json from the current directory, spawns the named server, performs the handshake, calls one tool, and prints the response envelope — the fastest way to confirm the server works before blaming the host.

mcp-call ll-mcp/capabilities '{}'

Its exit code tells you which layer failed: 0 success, 1 tool error, 124 timeout, 127 server or tool not found, 2 config/usage error.

The nine read tools, end to end

# Open issues, filtered and sorted by priority
mcp-call ll-mcp/issues_query '{"issue_type": "EPIC", "limit": 2}'
[
  {
    "id": "EPIC-2790",
    "priority": "P1",
    "type": "EPIC",
    "title": "Subprocess and MCP Robustness",
    "path": "/abs/path/.issues/epics/P1-EPIC-2790-subprocess-and-mcp-robustness.md",
    "status": "open",
    "parent": null,
    "labels": []
  }
]
# One issue's full summary card — accepts 3122, FEAT-3122, or P3-FEAT-3122
mcp-call ll-mcp/issue_get '{"issue_id": "3122"}'
{
  "issue_id": "FEAT-3122",
  "title": "ll-doctor advisor-reachability check",
  "priority": "P3",
  "status": "Open",
  "raw_status": "open",
  "confidence": "50",
  "outcome": "58",
  "path": ".issues/features/P3-FEAT-3122-advisor-ll-doctor-reachability-check.md"
}

Note the two status fields: status is display-cased for rendering, raw_status is the frontmatter value. Match on raw_status in any automation.

# FTS5 search over .ll/history.db
mcp-call ll-mcp/history_search '{"query": "handoff", "limit": 1}'
[
  {
    "content": "**/.handoff*",
    "kind": "file",
    "ref": "**/.handoff*",
    "anchor": "Glob",
    "ts": "2026-06-04T06:48:07Z",
    "score": -9.998166573997038
  }
]

score is FTS5 BM25: more negative is a better match. An empty result is normal on a young project — .ll/history.db only fills as sessions accumulate.

# Cross-issue dependency graph health
mcp-call ll-mcp/deps_check '{}'
{
  "has_issues": true,
  "broken_refs": [],
  "missing_backlinks": [["ENH-2997", "ENH-2991"]],
  "cycles": [],
  "stale_completed_refs": [["FEAT-2102", "FEAT-1932"]],
  "broken_depends_on_refs": [],
  "broken_relates_to_refs": []
}

has_issues means "this project has issue files", not "problems were found" — read the individual lists for that.

# Resolved host CLI and its capability surface
mcp-call ll-mcp/capabilities '{}'
{
  "host": "claude-code",
  "binary": "claude",
  "version": "",
  "capabilities": [
    {"name": "streaming", "status": "full", "note": ""},
    {"name": "claude_md_suppression", "status": "unsupported", "note": "the claude CLI has no flag to skip CLAUDE.md"}
  ]
}

This reports the host little-loops itself would drive for automation (per LL_HOST_CLI / orchestration.host_cli), not the MCP client you are calling from.

# Persisted ll-queue entries
mcp-call ll-mcp/queue_list '{}'
[
  {
    "id": "a1b2c3d4e5f6",
    "action": { "name": "rn-refine", "runner": "loop", "target": "FEAT-3122", "args": [], "timeout": null },
    "enqueuedAt": "2026-08-20T10:00:00Z",
    "priority": 0,
    "status": "queued",
    "result": null,
    "claimedAt": null,
    "ownerPid": null,
    "attempt": 0,
    "nextAttemptAt": null
  }
]
# One queue entry by full id or 8+-char prefix
mcp-call ll-mcp/queue_get '{"id": "a1b2c3d4"}'
{
  "id": "a1b2c3d4e5f6",
  "action": { "name": "rn-refine", "runner": "loop", "target": "FEAT-3122", "args": [], "timeout": null },
  "enqueuedAt": "2026-08-20T10:00:00Z",
  "priority": 0,
  "status": "queued",
  "result": null,
  "claimedAt": null,
  "ownerPid": null,
  "attempt": 0,
  "nextAttemptAt": null
}
# The project's loop catalog
mcp-call ll-mcp/loop_list '{}'
[
  {
    "name": "rn-refine",
    "path": "/abs/path/loops/rn-refine.yaml",
    "category": "refine",
    "labels": ["autonomous"],
    "visibility": "public",
    "description": "Refine an issue until implementation-ready."
  }
]
# The install's skills and commands, anchored at the plugin root
mcp-call ll-mcp/skills_list '{}'
[
  {"name": "manage-issue", "kind": "skill", "description": "Implement an issue end-to-end.", "args": "ISSUE_ID"},
  {"name": "commit", "kind": "command", "description": "Create a git commit."}
]

queue_list/queue_get mirror ll-queue list/ll-queue status; loop_list mirrors ll-loop list — each returns the same JSON shape as its --json CLI form. skills_list (ENH-3444) has no CLI equivalent — it anchors at the plugin root the engine itself resolves rather than --project-root, so every returned name classifies identically under queue_add; args is omitted when a skill/command has no frontmatter hint.


Resources and Prompts in Practice

Both surfaces are enumerated once at startup, but the enumeration self-heals within the same session (ENH-3172): every resources/list, resources/read, prompts/list, and prompts/get call cheaply checks whether the watched directories (issue category dirs, .ll/ll-goals.md, docs/, the skills root) changed since the last build, and rebuilds the index first if so. A newly created issue becomes readable as ll://issues/<ID> on the next call after it's written — no restart needed — and a deleted or renamed one stops being advertised the same way. The same applies to a newly added SKILL.md and the prompts list.

Two caveats follow from how that check works:

  • The check is a directory mtime, not a recursive walk — a change directly inside a watched directory is detected; a change two or more levels down (a new SKILL.md nested under an existing skill subdirectory, a new file in an existing docs/ subdirectory) is not. A subsequent unrelated top-level change will still pick it up.
  • The rebuild happens lazily, on the next call to one of the four methods above — not on a background timer. A client that only ever calls issues_query/issue_get and never touches the resource/prompt surface won't trigger a rebuild, but it also isn't observing the stale data either.
  • Clients that respect the server's 5-minute public CacheHint on these methods (and don't listen for change events) may still serve their own cached copy of a pre-rebuild response for up to 5 minutes — a client-side cache the server can't reach into. A client that opens a subscriptions/listen stream (resources_list_changed=true / prompts_list_changed=true) gets a notifications/resources/list_changed / notifications/prompts/list_changed event the moment a rebuild happens and should evict its cache and re-fetch, rather than waiting out the window.

Two more practical notes:

  • The resource list is bounded and paginated (ENH-3174). On a mature project it is still one entry per issue plus one per file under docs/ — this repository enumerates over 3,000 — but resources/list now caps each response at mcp.resources.page_size entries (default 500) and returns nextCursor when more remain; pass that value back as cursor on the next call to page through the rest. This is unconditional — it applies whether or not the config below is set. An operator can also narrow what gets enumerated at all via .ll/ll-config.json:
    {
      "mcp": {
        "resources": {
          "issue_statuses": ["open", "in_progress"],
          "docs_globs": ["guides/*.md", "reference/*.md"],
          "page_size": 200
        }
      }
    }
    
    issue_statuses/docs_globs default to null (unset), which enumerates every issue status and every docs/**/*.md file — identical to pre-ENH-3174 behavior, so an existing client sees no change unless this block is added. Either way, prefer issues_query to find an issue and resources/read (or issue_get) to fetch the one you want — the resource list is for browsing/discovery, not the fast path.
  • Prompts serve the full skill catalog on every install source (BUG-3177), not only a plugin checkout. The skills root is resolved in order: $LL_MCP_SKILLS_ROOT if set and valid, then $CLAUDE_PLUGIN_ROOT/skills if set and valid, then the copy shipped inside the installed little_loops package (present on both pypi and local-editable wheel/sdist builds), then the checkout-relative fallback (editable installs run straight from source). If none of those resolve, the server logs an ERROR: line on stderr naming every path it tried and serves an empty prompt list rather than failing silently — check the host's server log for that line if prompts/list comes back empty unexpectedly. Set LL_MCP_SKILLS_ROOT in the server's env block to point at an arbitrary skills directory (e.g. a different checkout) if you need to override the default resolution.

Resource and prompt listings carry ttlMs/cacheScope cache hints (5 minutes, public), so a well-behaved client will not re-enumerate on every request.

The ui:// interactive-resource scheme (ENH-3306)

Alongside the ll:// data resources, resources/list always advertises one MCP Apps–compliant interactive resource: ui://issues/view, with mimeType: "text/html;profile=mcp-app". issue_get links to it via _meta.ui.resourceUri on its tool definition, so a client that negotiated the io.modelcontextprotocol/ui extension at initialize can resources/read the view and render it inline instead of raw JSON. Clients that never negotiated that capability just never resolve it — it costs an unconditional resources/list entry, nothing more.

Unlike every other resource kind, ui://issues/view:

  • Is static package data, not a project file — it's read via importlib.resources from inside the installed wheel, not from config.project_root, and is therefore never in _watched_paths() / never goes stale from a project-side edit.
  • Carries no resource-level _meta.ui (no CSP or permissions declaration) — the template is fully self-contained (no external stylesheets, fonts, images, or fetch/XHR), so the host's default sandbox applies.
  • Speaks a minimal view-side handshake only: it renders ui/notifications/tool-result (reading structuredContent, falling back to JSON.parse(content[0].text)), tears down cleanly on ui/resource-teardown, and emits only ui/message — level 1 ("notify") on the Artifact Control Levels taxonomy. It never issues tools/call back into the FSM; level-2/3 interactions are out of this scope.

Adding a Tool

Never let a handler print. On the stdio transport, stdout is the JSON-RPC frame — anything a handler writes to stdout (directly, or by calling a cmd_* CLI function that prints its result) corrupts the protocol. The client-visible symptom is a JSON parse error that points nowhere near the offending tool, so this is the single most likely defect when adding a tool.

Two mitigations cover every case in this codebase today:

  1. Prefer extracting a non-printing library function. _tool_issue_set_status and _tool_issue_link (mcp_server/tools.py:317-420) never call cmd_set_status/cmd_link — they call apply_status_transition/apply_link, the non-printing functions FEAT-3149 extracted from those cmd_* implementations for exactly this reason. When the CLI function you're wrapping still prints, extract its logic first rather than wrapping the printing function.
  2. redirect_stdout/redirect_stderr when extraction isn't practical. _tool_loop_start (mcp_server/tools.py:699-705) wraps run_background() — which prints on both its success and pre-flight-failure paths — in contextlib.redirect_stdout/redirect_stderr, reading the captured stderr back to build an error message on non-zero return. Reach for this only when the wrapped call is otherwise unsafe to extract.

Registration checklist for a new tool:

  • Add the handler function and register it in _TOOL_HANDLERS (mcp_server/tools.py).
  • Add its types.Tool definition to _TOOLS, in the same source order as _TOOL_HANDLERS. No test asserts _TOOL_HANDLERS/_TOOLS parity — a tool registered in one but missed in the other is caught only if some test happens to call it, not by a dedicated gate, so double-check both by hand.
  • If the tool writes, add its name to policy.MUTATING_TOOLS — never TASK_STARTING_TOOLS, which is reserved for loop_start's "start a run" semantics (no coherent dry-run, gated by allow_tasks instead of allow_mutations). A mutating tool gets Guard 1's apply dry-run wrapper automatically once it's in MUTATING_TOOLS; a tool omitted from both registries is unguarded — reachable and able to write with no dry-run and no per-transport policy check.

The Mutation Surface and Its Guards

Four tools write: issue_capture, issue_set_status, issue_link, and issue_append_log. Each wraps the same library function the equivalent ll-issues subcommand calls, so a tool call and a CLI invocation produce the same file state.

ll-auto, ll-parallel, and ll-action invoke are still off the surface entirely. ll-loop is the one exception: loop_start (below, alongside tasks/*) starts a detached run. Everything else about the boundary is unchanged — tasks/cancel is a control operation over a run that is already going, signalling an existing PID, never spawning one.

Two guards sit in front of the four.

Guard 1 — dry-run by default

Every mutating tool takes an apply parameter that defaults to false. Called without it, the tool returns the change it would make and writes nothing:

// tools/call issue_set_status {"issue_id": "FEAT-3149", "status": "deferred"}
{
  "applied": false,
  "tool": "issue_set_status",
  "target": { "issue_id": "FEAT-3149", "path": ".issues/features/P3-FEAT-3149-….md" },
  "changes": [ { "field": "status", "from": "open", "to": "deferred" } ]
}

Re-call with "apply": true to perform it. The default is a refusal to mutate, not an opt-out flag: a host that omits the parameter entirely does not write, and the check is fail-closed — only the literal boolean true opts in. "true", 1, and null are all dry-runs.

One shape differs. A dry-run issue_capture returns no issue ID, not even a predicted one — it reports the type, priority, slug, target directory, and rendered body instead. The ID is allocated inside create_issue's lock hold at write time, so any ID named before apply is a guess that is wrong precisely when it matters: when something else allocated concurrently. The apply response carries the real one.

Guard 2 — per-transport policy

Whether the mutating tools may run at all is a deployment choice, set per transport in .ll/ll-config.json:

{
  "mcp": {
    "transport_policy": {
      "http":  { "allow_mutations": false },
      "stdio": { "allow_mutations": true }
    }
  }
}

Those are the defaults. HTTP denies mutations because that transport ships without authentication, so the posture for a transport a remote host can reach is read-only until someone opts in; stdio is a same-machine, same-user channel and defaults open. One server build serves both.

A denied call is refused at the transport layer, before the JSON-RPC body is parsed — ASGI middleware reads the SEP-2243 Mcp-Method / Mcp-Name routing headers off the raw request and answers with a JSON-RPC error and HTTP 403. Reads on the same server are unaffected:

$ # with http.allow_mutations = false
$ mcp-call ll-mcp tools/call issue_set_status '{"issue_id":"FEAT-1","status":"done"}'
{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"policy denied tools/call/issue_set_status: …"}}

$ mcp-call ll-mcp tools/call issues_query '{}'
[ … works fine … ]

This is sound against a spoofed header even though the middleware never sees the body: the SDK independently rejects any request whose Mcp-Method/Mcp-Name disagree with its body (HEADER_MISMATCH, -32020), and both headers are mandatory for tools/call. A request cannot reach a mutating handler while hiding its identity from the guard.

Distinguishing the two groups from a client

The seven mutating tools carry a readOnlyHint: false annotation in tools/list; the nine read-only tools carry no annotations at all. A host can key presentation — a confirmation prompt, a different icon — off that.


Starting, Polling, and Stopping a Run

loop_start starts a detached ll-loop run; tasks/get and tasks/cancel poll or stop it (or a run started by other means, e.g. ll-loop run on the workstation). All three share one grant (allow_tasks, below) and one identifier space (instance_id), so a host can start a run from a phone-side session and poll/stop it from a workstation session without SSH-ing anywhere. ll-queue is out of scope: only ll-loop runs are reachable this way.

Starting a run: loop_start

$ mcp-call ll-mcp tools/call loop_start '{"loop": "rn-refine", "context": ["ISSUE_ID=FEAT-3151"]}'
{"instance_id": "rn-refine-20260814T160000-a1b2", "loop": "rn-refine"}

loop_start always performs the identical detached spawn — the same one ll-loop run --background does — regardless of caller. What differs is the response shape, per SEP-2663 (the MCP "tasks" extension): a client that declared the tasks extension in its per-request capabilities and asked for task-augmented execution on that call gets back a task-shaped result instead:

$ # client declares the tasks extension and sets params.task on this call
{"resultType": "task", "taskId": "rn-refine-20260814T160000-a1b2", "status": "working"}

Any other client — one that never declared the extension, declared it but did not set params.task on this call, or is on a pre-2026-07-28 protocol version — gets the ordinary shape above, instance_id and all. Either way the run started; only the envelope changes. The task id is the run's instance_id verbatim (minted with a short entropy suffix, not ll-loop's own one-second-resolution id, to stay unique under agent-paced calls) and is what tasks/get/tasks/cancel accept below.

If the run cannot be spawned — a scope conflict, an unloadable loop — the call returns an ordinary tool error (isError: true) carrying the reason. It never returns a task id or an instance_id for a run that does not exist.

loop_start is not one of the seven mutating tools above: a dry-run "start" has no coherent meaning, so it takes no apply parameter and is gated by allow_tasks (below) instead of allow_mutations.

Polling and stopping: tasks/*

These are not tools — they are custom JSON-RPC methods registered directly on the server, shaped to track the (not-yet-shipped) io.modelcontextprotocol/tasks extension so a future swap to the official mechanism is a registration change, not a client-visible one. initialize's capabilities never advertise the extension itself — the server does not claim a capability it only implements privately.

taskId is the ll-loop instance_id verbatim — the same string ll-loop status already prints — not a handle minted by the server:

$ mcp-call ll-mcp tasks/get '{"taskId": "rn-refine-20260811T140000"}'
{"taskId": "rn-refine-20260811T140000", "status": "working", "runStatus": "running"}

The status field reconciles PID liveness before ever reporting "working" — a run whose process died (OOM, kernel kill) without updating its state file is reported not-running, not left "working" forever. When no PID is resolvable at all, an updated_at-age fallback (6h threshold) catches permanently PID-less orphans the same way (BUG-3317). Once the run is terminal, the result also carries the ExecutionResult fields (final_state, iterations, terminated_by, duration_ms, captured):

$ mcp-call ll-mcp tasks/get '{"taskId": "rn-refine-20260811T140000"}'
{"taskId": "…", "status": "completed", "runStatus": "completed", "final_state": "done", "iterations": 12, "terminated_by": "completed", "duration_ms": 483000, "captured": { … }}

An unknown taskId is a distinct JSON-RPC error, never a default "working" shape:

$ mcp-call ll-mcp tasks/get '{"taskId": "no-such-run"}'
{"jsonrpc":"2.0","id":null,"error":{"code":-32002,"message":"no run found for taskId 'no-such-run'"}}

tasks/cancel stops a running instance the same way ll-loop stop does — SIGTERM, then SIGKILL after a 10s grace period. Neither backend has a genuinely terminal "cancelled" status, so the result never reports "cancelled" bare: resumable and the backend's raw status ride alongside, so a host cannot mistake a resumable stop for an irreversible one:

$ mcp-call ll-mcp tasks/cancel '{"taskId": "rn-refine-20260811T140000"}'
{"taskId": "…", "status": "cancelled", "resumable": true, "runStatus": "user_stopped"}

tasks/* and loop_start get the same deny-by-default-on-HTTP treatment as the mutating tools (Guard 2 above), but as their own grant — allow_tasks, not allow_mutations. Consenting to issue-file writes over HTTP does not imply consenting to starting or stopping a running agent; starting one is the same class of authority as stopping it, so both sit behind one grant:

{
  "mcp": {
    "transport_policy": {
      "http":  { "allow_mutations": false, "allow_tasks": false },
      "stdio": { "allow_mutations": true,  "allow_tasks": true }
    }
  }
}

Those are the defaults — both closed on HTTP, both open on stdio. A denied tasks/get reports itself as a tasks/get denial (not a tools/call one), and a denied loop_start reports itself as a tools/call/loop_start denial, since the underlying guard is shared with Guard 2 but every method/tool is gated independently.

Enforcement is uniform across both transports: the tools/call and tasks/* handlers themselves consult the policy (in addition to the HTTP transport's ASGI middleware, which still denies before the JSON-RPC body is parsed on that transport), so a denied call over stdio returns the same -32001 JSON-RPC error the HTTP path returns.


Troubleshooting

Symptom Likely cause Fix
Client reports the server failed to start mcp extra not installed pip install "little-loops[mcp]"; run ll-mcp in a terminal to see the real stderr
Spawn error / command not found, but ll-mcp works in your shell GUI client does not inherit shell PATH Use the absolute path from which ll-mcp in the config
Every tool succeeds but returns empty results Server resolved the wrong project root (spawned from $HOME, no root given) Call capabilities and check project_root.resolved; add --project-root /abs/path (or LL_MCP_PROJECT_ROOT) — see above
A new issue is missing from resources/list but issue_get finds it Client is serving its own cached resources/list response (5-minute public CacheHint) Call again with cache bypassed, or listen for notifications/resources/list_changed; see Resources and Prompts in Practice — the server itself self-heals on the next call
prompts/list is empty Skills root failed to resolve (rare — the wheel ships its own copy); check stderr for the ERROR: no skills directory found line Set LL_MCP_SKILLS_ROOT to a valid skills directory in the server's env
history_search always returns [] .ll/history.db absent or empty under the resolved root Confirm <project-root>/.ll/history.db exists (history accrues over sessions); check capabilities for the root the server actually resolved
mcp-call exits 127 .mcp.json missing from cwd, or no ll-mcp key in it Run mcp-call from the project root; ll-adapt --host claude-code --apply
mcp-call exits 124 Server started but never answered Check for a stale install: pip show little-loops, then re-run ll-adapt

For anything protocol-level, run the server by hand and speak to it directly — three lines of JSON on stdin is a complete session:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"debug","version":"1"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
  | ll-mcp

See Also