Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ Status legend: ✅ Implemented · 🚧 In progress · 📋 Proposed
| [shacl-alignment](shacl-alignment.md) | 📋 Proposed | Build on the SHACL standard: adopt `sh:`/`dash:` constraint terms inside templates (A) and export SHACL shapes for external validation (B) |
| [custom-domains](custom-domains.md) | 📋 Proposed | Serve a user's profile from their own domain |
| [draft-with-ai](draft-with-ai.md) | 📋 Proposed | Server-side "Draft with AI" nanopub authoring |
| [claude-code-chat](claude-code-chat.md) | 📋 Proposed | Chat panel backed by the user's local Claude Code, acting on Nanodash via an MCP endpoint (Tier 2 of [#434](https://github.com/knowledgepixels/nanodash/issues/434)) |
| [claude-code-chat](claude-code-chat.md) | 🚧 In progress | Chat panel backed by the user's local Claude Code, acting on Nanodash via an MCP endpoint (Tier 2 of [#434](https://github.com/knowledgepixels/nanodash/issues/434)) |

When a doc's status changes, update both its `**Status:**` line and the row here.
62 changes: 46 additions & 16 deletions docs/claude-code-chat.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Local Claude Code Chat (MCP + Embedded Chat Panel)

**Status:** 📋 Proposed
**Status:** 🚧 In progress

Let a locally running Nanodash instance connect to the user's local Claude
Code installation: a chat panel in the browser sends messages to Claude Code
Expand Down Expand Up @@ -83,6 +83,10 @@ claude --print \
process alive across turns; each chat message is written as a JSON user
message to stdin, response/tool-use events stream back as
newline-delimited JSON on stdout. No per-turn process startup cost.
- **Page context:** each user message is prefixed (invisibly to the user)
with a bracketed context line naming the in-app path the chat panel was
on when the message was sent, so Claude can resolve "this page" / "this
nanopub" references without asking.
- **Session lifecycle:** processes are reaped after an idle timeout; the
Claude session ID (from the init event) is remembered so a reaped
conversation can be revived with `--resume <id>`.
Expand All @@ -97,6 +101,16 @@ claude --print \
- **Feature detection:** the whole feature is off unless (a) it is enabled
in the Nanodash config and (b) the `claude` binary is found (configurable
path). Hosted multi-user instances keep it off (see Security).
- **Domain background:** a curated background text (nanopub anatomy, trusty
URIs and supersede/retract semantics, template placeholders, query
parameter naming, useful Nanodash paths, and the "you never publish —
the human does" rule) is shipped as classpath resources: a client-neutral
core (`chat/background.md`), which the `/mcp` endpoint also serves to all
clients as MCP `instructions`, plus a local-chat addendum
(`chat/background-local.md`); the subprocess gets both via
`--append-system-prompt`. It is deliberately small and contains no
CLI/signing instructions: the subprocess has no Bash or file tools, and
publishing stays human-only.

### 2. MCP endpoint (`/mcp`)

Expand Down Expand Up @@ -132,6 +146,12 @@ claude mcp add --transport http nanodash http://localhost:37373/mcp
and get "Claude Code with Nanodash tools" -- which is precisely Tier 2 of
#434. The chat panel is an optional layer on top.

This standalone value extends to hosted multi-user instances: with
[remote MCP access](remote-mcp.md) enabled, users generate personal API
tokens and point their own agents (local Claude Code, Claude API
`mcp_servers`, any bearer-token MCP client) at the instance's `/mcp` —
without the chat panel or server-side subprocesses being involved at all.

### 3. Tool catalog

First wave, read-only (safe under `dontAsk`):
Expand All @@ -140,15 +160,16 @@ First wave, read-only (safe under `dontAsk`):
| --- | --- |
| `search_nanopubs` | free-text / typed search via the existing query APIs |
| `get_nanopub` | fetch a nanopub as TriG by URI |
| `list_templates` / `get_template` | discover and inspect assertion templates |
| `get_latest_version` | resolve a nanopub URI to its latest version (supersedes chain) |
| `list_templates` / `get_template` | discover and inspect assertion templates; `get_template` resolves to the latest version |
| `get_space` | space info, members, views for a space IRI |
| `run_query` | run a published grlc query with parameters |

Second wave, action tools:

| Tool | Does |
| --- | --- |
| `prepare_publication` | build + validate a draft from template/params; returns a `/view?_nanopub_trig=...` preview URL. **Does not publish.** |
| `prepare_publication` | build a prefilled `/publish?template=...&param_...` form path from template (resolved to latest version) + params; array values fill the rows of repeatable statements (`param_x`, `param_x__1`, ...). **Does not publish.** |
| `open_page` | enqueue a browser navigation for the chat panel to execute |

Publishing itself is deliberately **not** a tool in v1: `prepare_publication`
Expand All @@ -160,6 +181,10 @@ signed flow. This keeps the human signature step human.
- A docked panel (or dedicated page, for v1) with a message list, input box,
and a visible "Claude is using tool X..." activity line driven by the
tool-use events in the output stream.
- A **stop button** shown while Claude is working: it sends a
`control_request` with subtype `interrupt` over the subprocess's stdin
(the stream-json control protocol), ending the turn with whatever was
produced so far; the session stays usable.
- **Streaming to the browser:** v1 uses `AjaxSelfUpdatingTimerBehavior`
polling the session's message queue -- dead simple, no new infrastructure.
Upgrade path: Wicket native WebSocket (`wicket-native-websocket-*`) or a
Expand All @@ -179,19 +204,24 @@ This feature hands a web page a channel into a process that runs as the
local user. The containment story:

- **Tool allowlist, not denylist:** the subprocess runs with
`--allowedTools "mcp__nanodash__*"` and `--permission-mode dontAsk` --
everything not allowlisted is denied. No `Bash`, no file tools, no
`WebFetch` unless deliberately enabled in config. Without this, a chat
message could ask Claude to run arbitrary shell commands.
- **Local-only by design:** the feature is opt-in config, intended for
`localhost` instances. On hosted multi-user instances it must stay off:
every visitor would share one machine's Claude subscription (against its
terms) and one machine's process privileges. The Tier 3 design is the
hosted answer.
- **MCP endpoint auth:** `/mcp` accepts only requests bearing a
per-process random token that Nanodash generates at startup and injects
into the spawned process's `--mcp-config` headers. Other local processes
(or other browser tabs) can't drive the tools directly.
`--allowedTools mcp__nanodash mcp__nanodash__* WebFetch` -- everything
not allowlisted is denied (headless mode denies instead of prompting).
No `Bash`, no file tools. Without this, a chat message could ask Claude
to run arbitrary shell commands. `WebFetch` is read-only and lets Claude
look at external resources nanopubs point to; the explicit allow rule
covers all domains.
- **Local-only by design:** the *chat* feature is opt-in config, intended
for `localhost` instances. On hosted multi-user instances it must stay
off: every visitor would share one machine's Claude subscription (against
its terms) and one machine's process privileges. The Tier 3 design is the
hosted answer for a built-in chat; [remote MCP access](remote-mcp.md) is
the hosted answer for bring-your-own-agent.
- **MCP endpoint auth:** `/mcp` accepts two credentials: the per-process
random token that Nanodash generates at startup and injects into the
spawned process's `--mcp-config` headers (so other local processes or
browser tabs can't drive the tools directly), and — only when
[remote MCP access](remote-mcp.md) is enabled — users' personal API
tokens, which resolve to the owning user.
- **UI commands are a fixed vocabulary** (navigate to an in-app path), not
arbitrary JavaScript.

Expand Down
101 changes: 101 additions & 0 deletions docs/remote-mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Remote MCP Access (Personal API Tokens)

**Status: 🚧 implemented on this branch, verification pending**

Nanodash's `/mcp` endpoint (built for the [local Claude Code chat](claude-code-chat.md))
can also be opened up on a hosted multi-user instance, so that users point *their own*
AI agents at it: a local Claude Code, an agent built on the Claude API, or any other
MCP client that can send a bearer token. The agent gets the same read-only tool catalog
as the local chat — searching and reading nanopublications, inspecting templates,
running queries, preparing (never performing) publications, and steering the user's
open Nanodash browser tab.

This inverts the deployment story of the local chat: there, Nanodash spawns the model
process; here, the user brings their own agent and Nanodash is just the MCP server.
The user's own subscription or API key pays for the model; the Nanodash operator only
serves tool calls.

## Enabling

Off by default. In `~/.nanopub/nanodash-preferences.yml`:

```
mcp-remote-enabled: true
```

or via the environment: `NANODASH_MCP_REMOTE_ENABLED=true`.

This is independent of `claude-chat-enabled` (the local chat feature): either can be
on without the other. `/mcp` answers if at least one of the two is enabled, and only
accepts the credentials of the enabled paths.

## Personal API tokens

A logged-in user creates tokens on their own user page (About tab, "🔑 Remote AI
access" section), each with a label. The token (`ndmcp_` + 64 hex characters) is shown
**once** at creation and cannot be retrieved again; the server stores only its SHA-256
hash, with owner, label, and creation/last-used timestamps, in
`~/.nanopub/nanodash-api-tokens.yml`. Tokens don't expire but can be revoked in the
same UI at any time.

An authenticated MCP call resolves the token to the owning user's IRI. That identity
is currently used only for `open_page` routing (below); all other tools are the same
public read-only queries the web UI performs.

## Client setup

**Local Claude Code:**

```
claude mcp add --transport http nanodash https://<instance>/mcp \
--header "Authorization: Bearer ndmcp_..."
```

**Claude API** (`mcp_servers` in the Messages API): use the token as
`authorization_token` of the server entry.

**Anything else** that speaks MCP's streamable HTTP transport in JSON mode and can
send an `Authorization: Bearer` header.

Protocol notes: the endpoint is POST-only JSON-RPC 2.0 (no SSE stream; GET answers
405, which streamable-HTTP clients take as "no stream offered"). Protocol versions
2024-11-05 through 2025-06-18 are accepted. The `initialize` response carries the
domain background (nanopub anatomy, template usage, the never-publish rule) as MCP
`instructions`, so any client's model gets the same grounding as the local chat.

## open_page on a hosted instance

The `open_page` tool queues navigations **per user** (not per browser session): any
Nanodash page the token's owner has open picks them up and navigates. To keep hosted
instances free of background polling, a page only polls (every 3 s) if, when it
rendered, the user's agent had made an authenticated call within the previous 30
minutes; the poll stops itself when that window lapses.

Consequences:

- A tab opened *before* the agent's first call doesn't react until the user navigates
or reloads once (the tool's return text tells the agent to say so).
- With several tabs open, whichever tab polls first executes the navigation.

## Security scope

- Tools are read-only plus browser navigation (validated in-app paths only) and
publish-form prefilling. **Nothing is ever published server-side**; publishing
stays behind the user's own review, signature, and click in the web UI.
- A leaked token therefore lets an attacker read public nanopub data (already
public), watch nothing, and queue navigations for the owner's open tabs — revoke
it in the UI.
- Tokens are stored hashed; the plaintext exists only in the creation response.
- The endpoint should only be exposed over HTTPS on hosted instances.

## Follow-ups (not implemented)

- **OAuth 2.1 authorization** (PKCE, RFC 9728 protected-resource metadata, dynamic
client registration or CIMD) — what claude.ai custom connectors need; would
piggyback on the ORCID login for the consent step.
- Rate limiting / abuse controls.
- SSE / full streamable-HTTP transport and `Mcp-Session-Id`.
- Per-token tool scoping and an operator/admin view of issued tokens.
- Serving only the local addendum via `--append-system-prompt` to the local chat
subprocess (it currently gets the core background twice: once there, once as MCP
instructions).
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
<artifactId>gson</artifactId>
<version>2.14.0</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.24.0</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
Expand Down
Loading