added sse and streamable http

This commit is contained in:
2026-04-12 21:55:51 -04:00
parent 22df70fe1c
commit 449797703d
3 changed files with 340 additions and 18 deletions

208
README.md
View File

@@ -17,6 +17,7 @@ PyVikunja turns a self hosted [Vikunja](https://vikunja.io) instance into long t
- [How Information Is Recalled](#how-information-is-recalled)
- [The Instructions Payload](#the-instructions-payload)
- [Setup](#setup)
- [Transports](#transports)
- [Client Configuration](#client-configuration)
- [Usage Patterns](#usage-patterns)
- [Token Savings](#token-savings)
@@ -45,19 +46,22 @@ PyVikunja fixes both by moving the memory out of the model and into a structured
| PyVikunja MCP |
+---------+---------+
|
| stdio
|
+---------------+---------------+
| |
+--------+---------+ +---------+--------+
| Claude Code | | Local LLM |
| (remote) | | (Ollama, |
| | | LM Studio) |
+------------------+ +------------------+
+-----------------+-----------------+
| | |
stdio SSE Streamable HTTP
| | |
+------+-------+ +------+-------+ +------+--------+
| Claude Code | | Cursor, n8n, | | Claude Code, |
| Claude | | older MCP | | Cursor, and |
| Desktop | | clients | | modern MCP |
| Local LLMs | | | | clients |
+--------------+ +--------------+ +---------------+
```
The agent speaks MCP. The MCP server speaks the Vikunja REST API. Vikunja stores everything on disk in its own database. Any number of agents *(remote or local, paid or free)* can point at the same MCP server and share the same memory.
PyVikunja supports all three MCP transports — stdio for local subprocess use, SSE for legacy HTTP clients, and Streamable HTTP for modern HTTP clients. Pick whichever your editor or agent supports. See [Transports](#transports) for the details.
## How Information Is Stored
Vikunja already has the right primitives for an agent brain. The MCP just teaches the agent how to use them consistently.
@@ -194,9 +198,65 @@ python3 test.py
You should see `OK` for `/info`, `/user`, `/projects`, and `/tasks`.
## Transports
MCP defines three transports. PyVikunja speaks all three — pick the one your client supports. stdio is the default, SSE and Streamable HTTP are selected with a CLI flag.
| Transport | Use when | Launched by | Endpoint |
| --------------- | --------------------------------------------------------------------------------------- | -------------------- | ------------------- |
| stdio | The client runs the server as a subprocess on the same machine. Zero network exposure. | The MCP client | stdin / stdout |
| SSE | You want a network endpoint compatible with older HTTP clients *(Cursor, n8n, Continue)*. | You, as a daemon | `GET /sse` + `POST /messages/` |
| Streamable HTTP | You want the current MCP HTTP transport. Single endpoint, session aware, supports resumable streams. | You, as a daemon | `POST/GET/DELETE /mcp` |
SSE is the legacy HTTP transport and is on the MCP spec's deprecation path, but it has the widest client support right now. Streamable HTTP is the current spec and is what new clients are adding. Running both gives you maximum compatibility.
**Launching:**
```
python3 server.py # stdio (default)
python3 server.py --transport stdio # explicit stdio
python3 server.py --transport sse --host 0.0.0.0 --port 8000
python3 server.py --transport http --host 0.0.0.0 --port 8000
```
The `sse` and `http` modes bind a uvicorn HTTP server on `--host:--port` *(default `127.0.0.1:8000`)*. They can also be set via the `VIKUNJA_MCP_TRANSPORT`, `VIKUNJA_MCP_HOST`, and `VIKUNJA_MCP_PORT` environment variables.
To run both SSE and Streamable HTTP at once, start two processes on different ports *(or put them behind a reverse proxy at different paths)*:
```
python3 server.py --transport sse --port 8000 &
python3 server.py --transport http --port 8001 &
```
Each process is independent and talks to the same Vikunja backend, so memories written through one are immediately visible through the other.
### Running SSE or HTTP as a systemd service
If you want the network transports to be persistent, run them as a systemd unit. Minimal example *(`/etc/systemd/system/vikunja-mcp.service`)*:
```ini
[Unit]
Description=PyVikunja MCP server
After=network-online.target
[Service]
Type=simple
User=vikunja-mcp
WorkingDirectory=/opt/pyvikunja
EnvironmentFile=/opt/pyvikunja/.env
ExecStart=/usr/bin/python3 /opt/pyvikunja/server.py --transport http --host 127.0.0.1 --port 8000
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
Then `systemctl enable --now vikunja-mcp` and point your clients at `http://127.0.0.1:8000/mcp`. Put it behind nginx or Caddy with TLS if you expose it beyond localhost.
## Client Configuration
The MCP server is launched by the client, not as a standalone daemon. Each client has its own config where you register MCP servers by command line.
The three transports use different config shapes. stdio clients launch the server as a subprocess. Network clients *(SSE, Streamable HTTP)* connect to an already running daemon by URL. Examples below cover Claude Code, Claude Desktop, Cursor, and local LLMs via Continue. The same principles apply to any other MCP client.
### Stdio
The client launches `server.py` as a subprocess and talks to it over the pipe. Simplest setup, zero network exposure.
**Claude Code** *(`~/.config/claude-code/mcp.json`)*:
```json
@@ -204,7 +264,7 @@ The MCP server is launched by the client, not as a standalone daemon. Each clien
"mcpServers": {
"vikunja": {
"command": "python3",
"args": ["/absolute/path/to/pyvikunja/mcp/server.py"],
"args": ["/absolute/path/to/pyvikunja/server.py"],
"env": {
"VIKUNJA_URL": "http://localhost:3456",
"VIKUNJA_TOKEN": "tk_your_token_here"
@@ -214,13 +274,29 @@ The MCP server is launched by the client, not as a standalone daemon. Each clien
}
```
**Claude Desktop** *(`~/.config/Claude/claude_desktop_config.json` on Linux, equivalent under `~/Library/Application Support/Claude/` on macOS)*:
**Claude Desktop** *(`~/.config/Claude/claude_desktop_config.json` on Linux, `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS)*:
```json
{
"mcpServers": {
"vikunja": {
"command": "python3",
"args": ["/absolute/path/to/pyvikunja/mcp/server.py"],
"args": ["/absolute/path/to/pyvikunja/server.py"],
"env": {
"VIKUNJA_URL": "http://localhost:3456",
"VIKUNJA_TOKEN": "tk_your_token_here"
}
}
}
}
```
**Cursor** *(`~/.cursor/mcp.json`, or the per project `.cursor/mcp.json`)*:
```json
{
"mcpServers": {
"vikunja": {
"command": "python3",
"args": ["/absolute/path/to/pyvikunja/server.py"],
"env": {
"VIKUNJA_URL": "http://localhost:3456",
"VIKUNJA_TOKEN": "tk_your_token_here"
@@ -237,13 +313,111 @@ experimental:
- name: vikunja
command: python3
args:
- /absolute/path/to/pyvikunja/mcp/server.py
- /absolute/path/to/pyvikunja/server.py
env:
VIKUNJA_URL: http://localhost:3456
VIKUNJA_TOKEN: tk_your_token_here
```
Any stdio capable MCP client *(Zed, Cursor, LM Studio, generic MCP runners)* uses the same `command` + `args` + `env` shape. Restart the client after editing its config.
Any stdio capable MCP client *(Zed, LM Studio, generic MCP runners)* uses the same `command` + `args` + `env` shape. Restart the client after editing its config.
### SSE
Start the server as a daemon *(see [Transports](#transports))*, then point your client at the URL. No subprocess, no environment variables in the client config — the server already has `VIKUNJA_URL` and `VIKUNJA_TOKEN` from its own `.env`.
Start the server once:
```
python3 server.py --transport sse --host 127.0.0.1 --port 8000
```
**Claude Code** *(`~/.config/claude-code/mcp.json`)*:
```json
{
"mcpServers": {
"vikunja": {
"type": "sse",
"url": "http://127.0.0.1:8000/sse"
}
}
}
```
**Cursor** *(`~/.cursor/mcp.json`)*:
```json
{
"mcpServers": {
"vikunja": {
"url": "http://127.0.0.1:8000/sse"
}
}
}
```
**Continue** *(`config.yaml`)*:
```yaml
experimental:
mcpServers:
- name: vikunja
type: sse
url: http://127.0.0.1:8000/sse
```
Claude Desktop does not support SSE directly — use the Streamable HTTP config below, or wrap the daemon in a stdio shim with [`mcp-proxy`](https://github.com/sparfenyuk/mcp-proxy) if you specifically need SSE.
### Streamable HTTP
Same pattern as SSE. Start the daemon, point the client at the `/mcp` URL. This is the current MCP HTTP transport and is what you should prefer when the client supports it.
Start the server once:
```
python3 server.py --transport http --host 127.0.0.1 --port 8000
```
**Claude Code** *(`~/.config/claude-code/mcp.json`)*:
```json
{
"mcpServers": {
"vikunja": {
"type": "http",
"url": "http://127.0.0.1:8000/mcp"
}
}
}
```
**Claude Desktop** *(`~/.config/Claude/claude_desktop_config.json`)*:
```json
{
"mcpServers": {
"vikunja": {
"type": "streamable-http",
"url": "http://127.0.0.1:8000/mcp"
}
}
}
```
**Cursor** *(`~/.cursor/mcp.json`)*:
```json
{
"mcpServers": {
"vikunja": {
"url": "http://127.0.0.1:8000/mcp"
}
}
}
```
**Continue** *(`config.yaml`)*:
```yaml
experimental:
mcpServers:
- name: vikunja
type: streamable-http
url: http://127.0.0.1:8000/mcp
```
For a local LLM setup where the local model runs on the same box, stdio is still the fastest path *(no TCP overhead, no daemon)*. Use SSE or Streamable HTTP when the client and server are on different machines, when multiple clients need to share one running server, or when you want the server to persist across client restarts.
## Usage Patterns
@@ -275,6 +449,10 @@ There is no universal savings number because it depends on how memory heavy your
| Agent creates duplicate Memory projects | Tell it to look up the Memory project id once per session and reuse it |
| Agent invents inconsistent labels | Strengthen the label namespace rules inside `INSTRUCTIONS` |
| Token usage does not drop | You are still pasting memory into prompts, stop doing that and let the agent fetch it |
| SSE or HTTP client can't connect | Verify the daemon is running *(`curl http://host:port/sse` or `POST /mcp`)*, check `--host` is reachable, check firewall |
| SSE client gets stuck "connecting" | The SSE transport needs `POST /messages/` to be reachable too, not just `GET /sse`. Don't block POSTs at your proxy |
| Streamable HTTP returns 307 redirect | Client is posting to `/mcp/` with a trailing slash. Point it at `/mcp` exactly, or allow redirects on the client side |
| `ModuleNotFoundError: starlette` / `uvicorn` | Reinstall deps: `pip install -r requirements.txt`. These are only needed for `sse` and `http` transports |
---

View File

@@ -1,3 +1,5 @@
aiohttp>=3.9
mcp>=1.0
python-dotenv>=1.0
starlette>=0.37
uvicorn>=0.30

148
server.py
View File

@@ -2,7 +2,9 @@
# PyVikunja - Developed by acidvegas in Python (https://git.acid.vegas)
# vikunja/mcp/server.py
import argparse
import asyncio
import contextlib
import os
import re
@@ -31,6 +33,11 @@ TOKEN = os.getenv('VIKUNJA_TOKEN', '')
SPEC_URL = BASE_URL + '/docs.json'
HEADERS = {'Authorization': f'Bearer {TOKEN}', 'Accept': 'application/json'}
TRANSPORT_CHOICES = ('stdio', 'sse', 'http')
DEFAULT_TRANSPORT = os.getenv('VIKUNJA_MCP_TRANSPORT', 'stdio').lower()
DEFAULT_HOST = os.getenv('VIKUNJA_MCP_HOST', '127.0.0.1')
DEFAULT_PORT = int(os.getenv('VIKUNJA_MCP_PORT', '8000'))
# Curated allowlist of endpoints the MCP exposes. Anything not in this set is
# dropped at build time. Keeps the tool surface small, predictable, and safe.
@@ -442,8 +449,12 @@ async def load_spec() -> dict:
return await resp.json(content_type=None)
async def main():
'''Start the Vikunja MCP server over stdio.'''
async def build_server() -> tuple:
'''Build a configured MCP Server instance and its backing aiohttp session.
Returns a (server, aiohttp_session) tuple. The caller is responsible for
closing the aiohttp session when the server shuts down.
'''
spec = await load_spec()
tools, index = await build_tools(spec)
@@ -463,6 +474,14 @@ async def main():
return [TextContent(type='text', text=await call_endpoint(session, op, arguments or {}))]
return server, session
async def run_stdio():
'''Serve the MCP protocol over stdio.'''
server, session = await build_server()
try:
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
@@ -470,6 +489,129 @@ async def main():
await session.close()
def run_sse(host: str, port: int):
'''Serve the MCP protocol over the legacy SSE HTTP transport.
Exposes two endpoints:
GET /sse - long-lived Server-Sent Events stream
POST /messages/ - client -> server JSON-RPC messages
:param host: Interface to bind the HTTP server to
:param port: TCP port to listen on
'''
try:
import uvicorn
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.responses import Response
from starlette.routing import Mount, Route
except ImportError:
raise ImportError('missing starlette/uvicorn (pip install starlette uvicorn)')
sse = SseServerTransport('/messages/')
state = {}
async def handle_sse(request):
server = state['server']
async with sse.connect_sse(request.scope, request.receive, request._send) as (read, write):
await server.run(read, write, server.create_initialization_options())
return Response()
@contextlib.asynccontextmanager
async def lifespan(_app):
server, aio_session = await build_server()
state['server'] = server
state['session'] = aio_session
try:
yield
finally:
await aio_session.close()
app = Starlette(
debug=False,
routes=[
Route('/sse', endpoint=handle_sse, methods=['GET']),
Mount('/messages/', app=sse.handle_post_message),
],
lifespan=lifespan,
)
uvicorn.run(app, host=host, port=port, log_level='info')
def run_http(host: str, port: int):
'''Serve the MCP protocol over the Streamable HTTP transport.
Exposes a single endpoint at /mcp that handles both GET (SSE stream for
server -> client messages) and POST (client -> server JSON-RPC messages)
per the current MCP Streamable HTTP spec.
:param host: Interface to bind the HTTP server to
:param port: TCP port to listen on
'''
try:
import uvicorn
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from starlette.applications import Starlette
from starlette.routing import Route
except ImportError:
raise ImportError('missing starlette/uvicorn (pip install starlette uvicorn)')
state = {}
class MCPApp:
'''ASGI wrapper so Starlette treats this as a mounted app rather than a request handler.'''
async def __call__(self, scope, receive, send):
await state['manager'].handle_request(scope, receive, send)
@contextlib.asynccontextmanager
async def lifespan(_app):
server, aio_session = await build_server()
manager = StreamableHTTPSessionManager(app=server, event_store=None, json_response=False, stateless=False)
state['manager'] = manager
state['session'] = aio_session
async with manager.run():
try:
yield
finally:
await aio_session.close()
app = Starlette(
debug=False,
routes=[Route('/mcp', endpoint=MCPApp())],
lifespan=lifespan,
)
uvicorn.run(app, host=host, port=port, log_level='info')
def parse_args():
'''Parse CLI arguments for transport selection.'''
parser = argparse.ArgumentParser(description='PyVikunja MCP server', formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--transport', choices=TRANSPORT_CHOICES, default=DEFAULT_TRANSPORT, help='Transport to expose the MCP server over')
parser.add_argument('--host', default=DEFAULT_HOST, help='Host interface for sse and http transports')
parser.add_argument('--port', default=DEFAULT_PORT, type=int, help='TCP port for sse and http transports')
return parser.parse_args()
def main():
'''Entry point. Dispatch to the selected transport.'''
args = parse_args()
if args.transport == 'stdio':
asyncio.run(run_stdio())
elif args.transport == 'sse':
run_sse(args.host, args.port)
elif args.transport == 'http':
run_http(args.host, args.port)
def openapi_to_json(t: str) -> str:
'''Map an OpenAPI 2 primitive type to a JSON Schema primitive type.
@@ -508,4 +650,4 @@ def sanitize_name(raw: str) -> str:
if __name__ == '__main__':
asyncio.run(main())
main()