Skip to content
Open
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
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,68 @@ Note:
- Default port is 27124 if not specified
- Default host is 127.0.0.1 if not specified

### MCP Transport

The server uses stdio transport by default. To expose it over HTTP, choose one of the HTTP-capable transports via either
command-line arguments or environment variables:

- Environment: set `MCP_TRANSPORT=streamable-http` (recommended) or `MCP_TRANSPORT=http`. You can also configure
`MCP_HTTP_HOST`, `MCP_HTTP_PORT`, `MCP_HTTP_ROOT_PATH`, and `MCP_HTTP_ALLOW_ORIGINS` (comma-separated) for advanced setups.
- Command line: pass `--transport streamable-http` (or `--transport http`) when launching, together with optional
`--http-host`, `--http-port`, `--http-root-path`, and repeated `--http-allow-origins` flags.

#### Transport options

**Streamable HTTP (`--transport streamable-http`)**
- Start the server with:
```bash
uv run mcp-obsidian --transport streamable-http
```
- Exposes a single endpoint at `http://<host>:<port>/mcp` that handles session negotiation, JSON-RPC POSTs, and Server-Sent
Events (SSE) streaming on the same path. This keeps sessions alive, supports resumability, and matches FastMCP’s latest
transport semantics.
- Works great with `mcp-remote`:
```json
{
"obsidian": {
"command": "/usr/local/bin/npx",
"args": [
"-y",
"mcp-remote",
"http://localhost:8800/mcp",
"--allow-http"
]
}
}
```

**SSE + POST (`--transport http`)**
- Start the server with:
```bash
uv run mcp-obsidian --transport http
```
- Exposes the classic pair of endpoints:
- `http://<host>:<port>/sse` for the outbound SSE stream.
- `http://<host>:<port>/messages/` where clients POST JSON-RPC requests (requests include a session ID provided by the SSE
stream).
- Example `mcp-remote` configuration:
```json
{
"obsidian": {
"command": "/usr/local/bin/npx",
"args": [
"-y",
"mcp-remote",
"http://localhost:8800/sse",
"--allow-http"
]
}
}
```

When running over HTTP, ensure the host/port are reachable by your client. Streamable HTTP requires an `mcp` installation that
includes streamable transport support—if it is missing, the server now reports a clear error.

## Quickstart

### Install
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description = "MCP server to work with Obsidian via the remote REST plugin"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"mcp>=1.1.0",
"mcp[http]>=1.20.0",
"python-dotenv>=1.0.1",
"requests>=2.32.3",
]
Expand Down
13 changes: 10 additions & 3 deletions src/mcp_obsidian/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
from . import server
import asyncio
import logging
import sys

from . import server

def main():
"""Main entry point for the package."""
asyncio.run(server.main())
logger = logging.getLogger("mcp-obsidian")
try:
asyncio.run(server.main(sys.argv[1:]))
except KeyboardInterrupt:
logger.info("Keyboard interrupt received, shutting down.")

# Optionally expose other important items at package level
__all__ = ['main', 'server']
__all__ = ['main', 'server']
247 changes: 242 additions & 5 deletions src/mcp_obsidian/server.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import json
import argparse
import asyncio
import logging
import os
from collections.abc import Sequence
from functools import lru_cache
from typing import Any
import os

from contextlib import asynccontextmanager

from dotenv import load_dotenv
from mcp.server import Server
from mcp.types import (
Expand Down Expand Up @@ -80,9 +83,66 @@ async def call_tool(name: str, arguments: Any) -> Sequence[TextContent | ImageCo
raise RuntimeError(f"Caught Exception. Error: {str(e)}")


async def main():
def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="MCP Obsidian server")
parser.add_argument(
"--transport",
choices=["stdio", "http", "streamable-http"],
help="Transport to use (defaults to MCP_TRANSPORT env var or stdio).",
)
parser.add_argument(
"--http-host",
dest="http_host",
help="Host for HTTP transport (defaults to MCP_HTTP_HOST env var or 127.0.0.1).",
)
parser.add_argument(
"--http-port",
dest="http_port",
type=int,
help="Port for HTTP transport (defaults to MCP_HTTP_PORT env var or 8800).",
)
parser.add_argument(
"--http-root-path",
dest="http_root_path",
help="Root path for HTTP transport (defaults to MCP_HTTP_ROOT_PATH env var).",
)
parser.add_argument(
"--http-allow-origins",
dest="http_allow_origins",
action="append",
help=(
"CORS origins to allow when using HTTP transport. "
"Repeat flag to add multiple origins (defaults to MCP_HTTP_ALLOW_ORIGINS env var)."
),
)
return parser.parse_args(argv)


def _resolve_http_options(args: argparse.Namespace) -> dict[str, Any]:
http_host = args.http_host or os.getenv("MCP_HTTP_HOST", "127.0.0.1")
http_port = args.http_port or int(os.getenv("MCP_HTTP_PORT", "8800"))
http_root_path = args.http_root_path or os.getenv("MCP_HTTP_ROOT_PATH")

if args.http_allow_origins is not None:
http_allow_origins = [origin.strip() for origin in args.http_allow_origins if origin.strip()]
else:
origins_env = os.getenv("MCP_HTTP_ALLOW_ORIGINS")
http_allow_origins = (
[origin.strip() for origin in origins_env.split(",") if origin.strip()]
if origins_env
else None
)

return {
"host": http_host,
"port": http_port,
"root_path": http_root_path,
"allowed_origins": http_allow_origins,
}


async def _run_stdio_transport() -> None:

# Import here to avoid issues with event loops
from mcp.server.stdio import stdio_server

async with stdio_server() as (read_stream, write_stream):
Expand All @@ -91,3 +151,180 @@ async def main():
write_stream,
app.create_initialization_options()
)


def _build_cors_middleware(allowed_origins: Sequence[str] | None):
if not allowed_origins:
return []

from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware

return [
Middleware(
CORSMiddleware,
allow_origins=list(allowed_origins),
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True,
)
]


async def _run_sse_http_transport(
*,
host: str,
port: int,
root_path: str | None,
allowed_origins: Sequence[str] | None,
) -> None:
try:
import uvicorn
from starlette.applications import Starlette
from starlette.responses import Response
from starlette.routing import Mount, Route
from mcp.server.sse import SseServerTransport
except ImportError as exc:
raise RuntimeError(
"HTTP transport requested, but required dependencies were not found. "
"Ensure the 'mcp' package is installed with HTTP support dependencies."
) from exc

sse_transport = SseServerTransport("/messages/")

async def handle_sse(request):
try:
async with sse_transport.connect_sse(request.scope, request.receive, request._send) as (read_stream, write_stream):
await app.run(
read_stream,
write_stream,
app.create_initialization_options(),
)
except Exception:
logger.exception("Error while handling SSE connection")
raise
return Response()

middleware = _build_cors_middleware(allowed_origins)

starlette_app = Starlette(
routes=[
Route("/sse", endpoint=handle_sse, methods=["GET"]),
Mount("/messages/", app=sse_transport.handle_post_message),
],
middleware=middleware,
)

config = uvicorn.Config(
starlette_app,
host=host,
port=port,
root_path=root_path or "",
log_level=logging.getLevelName(logger.getEffectiveLevel()).lower(),
)
server = uvicorn.Server(config)

logger.info("Starting HTTP (SSE) transport on http://%s:%s", host, port)
await server.serve()


class _StreamableHTTPASGIApp:
def __init__(self, session_manager):
self._session_manager = session_manager

async def __call__(self, scope, receive, send):
await self._session_manager.handle_request(scope, receive, send)


async def _run_streamable_http_transport(
*,
host: str,
port: int,
root_path: str | None,
allowed_origins: Sequence[str] | None,
) -> None:
try:
import uvicorn
from starlette.applications import Starlette
from starlette.routing import Route
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
except ImportError as exc:
raise RuntimeError(
"Streamable HTTP transport requested, but required dependencies were not found. "
"Ensure the installed 'mcp' package includes Streamable HTTP support."
) from exc

session_manager = StreamableHTTPSessionManager(app)
streamable_app = _StreamableHTTPASGIApp(session_manager)

middleware = _build_cors_middleware(allowed_origins)

@asynccontextmanager
async def lifespan(_starlette_app):
async with session_manager.run():
yield

starlette_app = Starlette(
routes=[Route("/mcp", endpoint=streamable_app)],
middleware=middleware,
lifespan=lifespan,
)

config = uvicorn.Config(
starlette_app,
host=host,
port=port,
root_path=root_path or "",
log_level=logging.getLevelName(logger.getEffectiveLevel()).lower(),
)
server = uvicorn.Server(config)

logger.info("Starting streamable-http transport on http://%s:%s/mcp", host, port)
await server.serve()


async def _run_http_transport(
*,
transport: str,
host: str,
port: int,
root_path: str | None,
allowed_origins: Sequence[str] | None,
) -> None:
if transport == "streamable-http":
await _run_streamable_http_transport(
host=host,
port=port,
root_path=root_path,
allowed_origins=allowed_origins,
)
return

await _run_sse_http_transport(
host=host,
port=port,
root_path=root_path,
allowed_origins=allowed_origins,
)


async def main(argv: Sequence[str] | None = None):
args = _parse_args(argv)
transport = (args.transport or os.getenv("MCP_TRANSPORT", "stdio")).lower()

try:
if transport in {"http", "streamable-http"}:
http_options = _resolve_http_options(args)
await _run_http_transport(
transport=transport,
host=http_options["host"],
port=http_options["port"],
root_path=http_options["root_path"],
allowed_origins=http_options["allowed_origins"],
)
elif transport == "stdio":
await _run_stdio_transport()
else:
raise ValueError(f"Unsupported transport '{transport}'. Use 'stdio', 'http', or 'streamable-http'.")
except asyncio.CancelledError:
logger.info("Shutdown requested, cancelling outstanding tasks.")
Loading