Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ A minimal, fast, JSON-LD native Solid server.
- **Tunnel Proxy** — Decentralized ngrok through your pod
- **Terminal** — WebSocket shell access via `--terminal`
- **Password CLI** — `jss passwd` for user password management
- **MCP Server** — Expose the pod as a tool surface for agents (Claude Desktop, Cursor, custom bots) via `--mcp` ([docs](docs/mcp.md))
- **HTTP 402 Payments** — Monetize endpoints with per-request sat payments
- **Mashlib / SolidOS UI** — Optional data browser (CDN, local, or ES module)
- **Storage Quotas** — Per-user limits with CLI management
Expand Down Expand Up @@ -92,7 +93,7 @@ jss quota <cmd> # Manage storage quotas
jss passwd <username> # Manage user passwords
```

Key options: `--port`, `--idp`, `--conneg`, `--mashlib`, `--git`, `--nostr`, `--activitypub`, `--webrtc`, `--tunnel`, `--terminal`, `--mongo`, `--pay`, `--public`, `--single-user`
Key options: `--port`, `--idp`, `--conneg`, `--mashlib`, `--git`, `--nostr`, `--mcp`, `--activitypub`, `--webrtc`, `--tunnel`, `--terminal`, `--mongo`, `--pay`, `--public`, `--single-user`

Full options: [docs/configuration.md](docs/configuration.md)

Expand All @@ -106,6 +107,7 @@ Full options: [docs/configuration.md](docs/configuration.md)
| WebSocket Notifications | [docs/notifications.md](docs/notifications.md) |
| Git Support | [docs/git-support.md](docs/git-support.md) |
| Installing Apps | [docs/app-install.md](docs/app-install.md) |
| MCP (pod as agent tool surface) | [docs/mcp.md](docs/mcp.md) |
| Nostr Relay | [docs/nostr.md](docs/nostr.md) |
| ActivityPub & Mastodon API | [docs/activitypub.md](docs/activitypub.md) |
| remoteStorage | [docs/remotestorage.md](docs/remotestorage.md) |
Expand Down
3 changes: 3 additions & 0 deletions bin/jss.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ program
.option('--no-mongo', 'Disable MongoDB-backed /db/ route')
.option('--mongo-url <url>', 'MongoDB connection URL (default: mongodb://localhost:27017)')
.option('--mongo-database <name>', 'MongoDB database name (default: solid)')
.option('--mcp', 'Enable MCP (Model Context Protocol) server at /mcp — pod as a tool surface for agents (#490)')
.option('--no-mcp', 'Disable MCP server')
.option('-q, --quiet', 'Suppress log output')
.option('--log-level <level>', 'Log level: error, warn, info, debug (default: info)')
.option('--print-config', 'Print configuration and exit')
Expand Down Expand Up @@ -234,6 +236,7 @@ program
mongo: config.mongo,
mongoUrl: config.mongoUrl,
mongoDatabase: config.mongoDatabase,
mcp: config.mcp,
});

await server.listen({ port: config.port, host: config.host });
Expand Down
121 changes: 121 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# MCP — pod as a tool surface for agents

JSS speaks the [Model Context Protocol](https://modelcontextprotocol.io). Once `--mcp` is enabled, any MCP-compatible client — Claude Desktop, Cursor, custom agents, or `solid-apps/charlie` — can register your pod as a tool surface and read/write resources under the same WAC rules as any HTTP client.

> **Thesis**: MCP needs a backend. Solid is the backend.

## Quick start

```bash
jss start --idp --mcp
```

The MCP endpoint is `POST /mcp` on your pod, speaking JSON-RPC 2.0 over MCP's Streamable HTTP transport (protocol version `2025-03-26`).

### Smoke test

```bash
# Handshake
curl -s http://localhost:4443/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' | jq

# List the tools the server offers
curl -s http://localhost:4443/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | jq '.result.tools[].name'

# Call a tool (anonymous read of /public/)
curl -s http://localhost:4443/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_resources","arguments":{"path":"/public/"}}}' | jq
```

## Auth

The MCP endpoint reuses JSS's existing auth chain. Any token format JSS accepts on regular HTTP endpoints works here:

- **Bearer** — simple HMAC tokens from `POST /idp/credentials`
- **Solid-OIDC + DPoP** — for federated WebID identities
- **LWS-CID JWTs** — kid resolution via WebID profile
- **NIP-98** — Schnorr-signed Nostr events with `did:nostr:<pubkey>` identity

The MCP server extracts the WebID from the inbound request to `/mcp` itself. Every tool call is then WAC-checked against that WebID, on the resource path the tool touches. **There is no separate MCP auth layer** — granting an agent access to `/private/notes/` is the same operation as granting a human: edit the ACL.

Anonymous requests get the same WAC treatment as any other anonymous request — public resources are reachable, private ones aren't.

## Tools

### Resource CRUD

| Tool | Effect | WAC check |
|---|---|---|
| `list_resources` | List a container's contents (`ldp:contains`) | Read on container |
| `read_resource` | Return resource body (UTF-8) | Read on resource |
| `write_resource` | PUT resource (overwrites) | Write on resource (parent fallback for new resources) |
| `create_resource` | POST to container (server mints name unless `slug` given) | Append on container |
| `delete_resource` | DELETE resource | Write on resource |
| `head_resource` | Return size/modified without body | Read on resource |

### Skill discovery

Skills live at conventional paths the MCP server walks:

- `<pod>/SKILL.md` — pod-wide (owner's instructions to any bot)
- `<pod>/public/apps/<name>/SKILL.md` — per-app
- `<pod>/private/bots/<name>/SKILL.md` — per-bot

| Tool | Returns |
|---|---|
| `list_skills` | `skill:SkillIndex` listing every discovered skill with `skill:format`, `skill:scope`, `skill:source` |
| `get_skill` | Body of a specific skill file |
| `get_pod_skill` | Pod-wide SKILL.md (convenience) |

Both `SKILL.md` (Anthropic markdown format) and `SKILL.jsonld` (typed JSON-LD descriptor) are first-class. The discovery channel stays stable; new formats plug in via the `skill:format` declaration.

### Docs

| Tool | Returns |
|---|---|
| `list_docs` | JSS's own built-in docs (the markdown files shipped with the server) |
| `read_docs` | Markdown body of a doc by filename |

Pod-resident docs (`/docs/`, `/public/apps/<name>/docs/`) are reachable via the regular CRUD tools — no separate surface.

### Introspection

| Tool | Returns |
|---|---|
| `pod_info` | Origin, server, MCP protocol version, authenticated identity, capability flags |

## Wiring Claude Desktop

In your Claude Desktop MCP settings, add an HTTP MCP server pointing at:

```
http://localhost:4443/mcp
```

For authenticated access, configure the client to send `Authorization: Bearer <token>`. Tokens come from `POST /idp/credentials` (username/password) or from any compatible OIDC/DPoP flow.

## What's not included (yet)

The first cut ships CRUD, ACL-as-resource (you can read/write `.acl` files via the regular tools), skills, docs, and introspection. Deferred:

- **`update_resource` (PATCH)** — SPARQL Update / N3 patches. Read-modify-write through the CRUD tools is the workaround.
- **`subscribe`** — wrap JSS's WebSocket notifications as MCP events over SSE. Today, agents can `read_resource` + poll.
- **`call_remote_pod`** — federation primitive for bot-to-bot. Today, an agent can talk to two pods by registering both as MCP servers in its client.

These are tracked as follow-ups on issue #490.

## Why this exists

The agent ecosystem has no shared answer for sovereign, ACL-gated storage. Every agent today bolts on its own DB, vector store, or secrets vault. Solid's pitch — user-owned data, queryable, access-controlled — is exactly what agents need. MCP is the wire that connects them.

When JSS exposes `/mcp`:

- **Agent identity becomes a first-class WAC subject.** `acl:agent <did:nostr:...>` for a bot is the same operation as for a human.
- **The pod is the bot's world.** A bot reads its instructions from `SKILL.md` on the pod, discovers tools as URL-addressable resources, and (with the owner's permission) writes back. No backend, no API key store, no secrets vault — just the pod.
- **Bot-to-bot falls out of the protocol.** Two pods running JSS can have their bots call each other's MCP endpoints, gated by WAC on both ends. No new federation wire.

See [#490](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/490) for the design discussion and roadmap.
5 changes: 5 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ export const defaults = {
mongoUrl: 'mongodb://localhost:27017',
mongoDatabase: 'solid',

// MCP (Model Context Protocol) server — pod as a tool surface for agents (#490)
mcp: false,

// Logging
logger: true,
quiet: false,
Expand Down Expand Up @@ -197,6 +200,7 @@ const envMap = {
JSS_MONGO: 'mongo',
JSS_MONGO_URL: 'mongoUrl',
JSS_MONGO_DATABASE: 'mongoDatabase',
JSS_MCP: 'mcp',
};

/**
Expand Down Expand Up @@ -242,6 +246,7 @@ const BOOLEAN_KEYS = new Set([
'liveReload',
'pay',
'mongo',
'mcp',
'idp',
'notifications',
'logger',
Expand Down
133 changes: 133 additions & 0 deletions src/mcp/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* MCP (Model Context Protocol) plugin.
*
* Usage:
* createServer({ mcp: true })
*
* Endpoint:
* POST /mcp (JSON-RPC 2.0, MCP Streamable HTTP transport)
*
* Auth:
* Reuses JSS's existing auth chain — Bearer / DPoP / NIP-98 — so
* the same WAC rules that gate /public, /private, etc. also gate
* tool calls. Anonymous requests get the same WAC treatment as
* any other anonymous request.
*
* Spec: https://spec.modelcontextprotocol.io/specification/2025-03-26/
*/

import {
PROTOCOL_VERSION,
SERVER_INFO,
RPC_ERRORS,
rpcResult,
rpcError
} from './protocol.js';
import { listToolsForRpc, callTool } from './tools.js';
import { getWebIdFromRequestAsync } from '../auth/token.js';

const ALLOWED_METHODS = new Set([
'initialize',
'initialized',
'notifications/initialized',
'tools/list',
'tools/call',
'ping'
]);

function originOf(request) {
const host = request.headers.host || request.hostname;
const proto = request.protocol || 'http';
return `${proto}://${host}`;
}

async function dispatch(msg, ctx) {
const { id, method, params } = msg;

if (!ALLOWED_METHODS.has(method)) {
return rpcError(id, RPC_ERRORS.METHOD_NOT_FOUND, `unknown method: ${method}`);
}

if (method === 'ping') {
return rpcResult(id, {});
}

if (method === 'initialize') {
return rpcResult(id, {
protocolVersion: PROTOCOL_VERSION,
serverInfo: SERVER_INFO,
capabilities: {
tools: { listChanged: false }
}
});
}

if (method === 'initialized' || method === 'notifications/initialized') {
// Notifications carry no id; nothing to return
return null;
}

if (method === 'tools/list') {
return rpcResult(id, { tools: listToolsForRpc() });
}

if (method === 'tools/call') {
const toolName = params?.name;
const toolArgs = params?.arguments || {};
if (!toolName) {
return rpcError(id, RPC_ERRORS.INVALID_PARAMS, 'tool name required');
}
const result = await callTool(toolName, toolArgs, ctx);
return rpcResult(id, result);
}

return rpcError(id, RPC_ERRORS.METHOD_NOT_FOUND, `unhandled method: ${method}`);
}

/**
* Register the MCP plugin with Fastify.
*/
export async function mcpPlugin(fastify, _options) {
fastify.post('/mcp', async (request, reply) => {
const body = request.body;
if (!body || typeof body !== 'object') {
reply.code(400);
return rpcError(null, RPC_ERRORS.INVALID_REQUEST, 'expected JSON-RPC body');
}

// Identity for tool calls — pulled from the inbound auth on /mcp itself.
// null webId means "anonymous"; WAC will treat it accordingly.
const { webId } = await getWebIdFromRequestAsync(request).catch(() => ({ webId: null }));

const ctx = {
webId: webId || null,
origin: originOf(request)
};

// Batch support (array of requests)
if (Array.isArray(body)) {
const out = [];
for (const msg of body) {
const r = await dispatch(msg, ctx);
if (r) out.push(r);
}
reply.header('Content-Type', 'application/json');
return out;
}

const result = await dispatch(body, ctx);
if (result === null) {
// Notification (no response body)
reply.code(204);
return null;
}
reply.header('Content-Type', 'application/json');
return result;
});

fastify.options('/mcp', async (_request, reply) => {
reply.header('Allow', 'POST, OPTIONS');
reply.code(204);
return null;
});
}
52 changes: 52 additions & 0 deletions src/mcp/protocol.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* MCP (Model Context Protocol) protocol envelope.
*
* Implements the Streamable HTTP transport for MCP 2025-03-26.
* Single endpoint (POST /mcp). Client sends JSON-RPC 2.0 requests.
* Server responds with JSON-RPC 2.0 responses (single-shot JSON for now;
* SSE streaming can be added later for subscribe-style tools).
*
* Spec: https://spec.modelcontextprotocol.io/specification/2025-03-26/
*/

export const PROTOCOL_VERSION = '2025-03-26';

export const SERVER_INFO = {
name: 'jss-mcp',
version: '0.1.0'
};

// JSON-RPC error codes
export const RPC_ERRORS = {
PARSE_ERROR: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL_ERROR: -32603,
// MCP-specific
TOOL_ERROR: -32000,
AUTH_REQUIRED: -32001,
ACCESS_DENIED: -32002
};

export function rpcResult(id, result) {
return { jsonrpc: '2.0', id, result };
}

export function rpcError(id, code, message, data) {
const err = { code, message };
if (data !== undefined) err.data = data;
return { jsonrpc: '2.0', id, error: err };
}

export function toolText(text) {
return { content: [{ type: 'text', text }], isError: false };
}

export function toolError(message) {
return { content: [{ type: 'text', text: message }], isError: true };
}

export function toolJson(value) {
return toolText(JSON.stringify(value, null, 2));
}
Loading