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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ on:

jobs:

test:
name: Run tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
python-version: "3.11"
- name: Run pytest
run: uv run --group dev pytest tests

build-macos:
name: Build jarvis binary + macOS app (arm64)
runs-on: macos-26
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
.venv/
.tokens/
__pycache__/
.pytest_cache/
.coverage
macOs/Jarvis/Jarvis.xcodeproj/xcuserdata/
macOs/Jarvis/Jarvis.xcodeproj/project.xcworkspace/xcuserdata/
macOs/Jarvis/Jarvis/Resources/jarvis
Expand Down
74 changes: 74 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# AGENTS.md

## What this is

Jarvis is an MCP proxy that aggregates multiple MCP servers behind 2 synthetic tools (`search_tools` + `call_tool`). Python 3.11+, managed with **uv**.

## Layout

```
src/jarvis/ # the package (6 modules)
__main__.py # CLI entrypoint — arg parsing, server startup
config.py # DATA_DIR, presets, config loading, OAuth wiring
proxy.py # builds FastMCP proxy (stdio vs HTTP client selection)
api.py # REST management API (runs on port+1)
probe.py # server/tool discovery
tui.py # Textual TUIs (mcp manager, auth manager)
tests/unit/ # pure unit tests
tests/integration/ # API endpoint + TUI tests
scripts/ # PyInstaller build scripts
macOs/ # Xcode project for the menu bar app
```

## Commands

```bash
# Install deps (no separate install step — uv handles it)
uv sync --group dev

# Run locally
uv run python -m jarvis --http 7070

# Run all tests
uv run --group dev pytest tests

# Run a single test file or test
uv run --group dev pytest tests/unit/test_config.py
uv run --group dev pytest tests/unit/test_config.py::test_name -k test_name

# Build standalone binary (macOS arm64)
bash scripts/build_jarvis_binary.sh

# Build standalone binary (Linux x86_64)
bash scripts/build_jarvis_binary_linux.sh
```

## Testing quirks

- **pytest-asyncio `auto` mode** is on (`asyncio_mode = "auto"` in pyproject.toml). Do not add `@pytest.mark.asyncio` to async tests.
- **`conftest.py` sets `JARVIS_DATA_DIR` at import time** before any jarvis module is imported. This isolates tests from `~/.jarvis`. If you add a new conftest or rearrange imports, preserve this ordering — the module-level `DATA_DIR` and `token_storage` in `config.py` bind once on first import.
- Use the `data_dir` fixture for per-test isolation. It monkeypatches `DATA_DIR`, `PRESETS_PATH`, and `token_storage` across `config`, `api`, and `probe` modules.
- Use the `servers_json` fixture when you need a pre-populated `servers.json` in the isolated data dir.

## Architecture notes

- `config.py` resolves `DATA_DIR` and creates `token_storage` (DiskStore) **at module level**. The env var `JARVIS_DATA_DIR` overrides the default `~/.jarvis` — this is the only mechanism for test isolation.
- `proxy.py` chooses `StatefulProxyClient` (persistent subprocess) for stdio servers and `ProxyClient` (fresh connection) for HTTP/SSE. The stateful clients are pinned to `mcp._stateful_clients` to avoid GC.
- The hatchling build uses `packages = ["src/jarvis"]` — the wheel package is `jarvis`, not `jarvis_mcp`.

## macOS app

The menu bar app (`macOs/Jarvis/`) is a Swift/Xcode project that embeds the PyInstaller binary. **Build order matters** — the binary must exist before Xcode can bundle it:

```bash
# 1. Build the Python binary into the Xcode Resources dir
bash scripts/build_jarvis_binary.sh # → macOs/Jarvis/Jarvis/Resources/jarvis

# 2. Build the app
xcodebuild -project macOs/Jarvis/Jarvis.xcodeproj -scheme Jarvis -configuration Debug build
```
## CI

- Every push/PR: pytest + binary builds (macOS arm64, Linux x86_64).
- No lint or typecheck step in CI. Ruff cache exists locally but there is no enforced config.
- Releases trigger on `v*` tags and produce binaries + a macOS `.dmg`.
1 change: 1 addition & 0 deletions CLAUDE.md
11 changes: 3 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,17 +133,13 @@ Lists all servers and their auth status. `l` to trigger the OAuth login flow for

## OAuth authentication

Servers with `"auth": "oauth"` require a one-time browser login. Use the `auth` TUI (above), or authenticate from the CLI:
Servers with `"auth": "oauth"` require a one-time browser login. Use the `auth` TUI to trigger the flow:

```bash
# All OAuth servers
jarvis --auth

# Specific server
jarvis --auth atlassian
jarvis auth
```

Tokens are stored in `~/.jarvis/` and reused automatically on subsequent runs.
Select the server and press `l` to open the browser login flow. Tokens are stored in `~/.jarvis/` and reused automatically on subsequent runs.

## Modes

Expand Down Expand Up @@ -173,7 +169,6 @@ Commands:
Options:
--config PATH Use a specific config file
--http PORT Run as an HTTP server on PORT (management UI)
--auth [SERVER] Authenticate with all servers or a specific one
--code-mode Enable code mode transform
--help, -h Show this message and exit

Expand Down
3 changes: 1 addition & 2 deletions macOs/Jarvis/Jarvis/Services/LogViewerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,7 @@ struct LogViewerView: View {
if let content = try? String(contentsOf: logURL, encoding: .utf8) {
// Get last 10000 lines to avoid memory issues
let lines = content.split(separator: "\n", omittingEmptySubsequences: false)
let recentLines = lines.suffix(10000)
logContent = recentLines.joined(separator: "\n")
logContent = lines.suffix(10000).joined(separator: "\n")
} else {
logContent = "No logs found at \(logURL.path(percentEncoded: false))\n\nThe log file will be created when the server starts."
}
Expand Down
11 changes: 11 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,20 @@ dependencies = [
"textual>=0.83",
]

[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"coverage[toml]>=7.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/jarvis"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
2 changes: 2 additions & 0 deletions scripts/build_jarvis_binary.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ uv run --with 'pyinstaller==6.19.0' pyinstaller \
--copy-metadata starlette \
--copy-metadata uvicorn \
--copy-metadata textual \
--copy-metadata pydantic-monty \
--hidden-import pydantic_monty \
"$REPO_ROOT/src/jarvis/__main__.py"

echo "==> Done. Binary at: $OUT_DIR/jarvis"
Expand Down
2 changes: 2 additions & 0 deletions scripts/build_jarvis_binary_linux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ uv run --with 'pyinstaller==6.19.0' pyinstaller \
--copy-metadata starlette \
--copy-metadata uvicorn \
--copy-metadata textual \
--copy-metadata pydantic-monty \
--hidden-import pydantic_monty \
"$REPO_ROOT/src/jarvis/__main__.py"

echo "==> Done. Binary at: $OUT_DIR/jarvis"
Expand Down
65 changes: 6 additions & 59 deletions src/jarvis/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pathlib import Path

from fastmcp.mcp_config import MCPConfig
from fastmcp.server import create_proxy
from jarvis.proxy import build_proxy
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.server.transforms.search import BM25SearchTransform

Expand Down Expand Up @@ -58,7 +58,6 @@
"Options:\n"
" --config PATH Use a specific config file\n"
" --http PORT Run as an HTTP server on PORT (management UI)\n"
" --auth [SERVER] Authenticate with all servers or a specific one\n"
" --code-mode Enable code mode transform\n"
" --help, -h Show this message and exit\n"
"\n"
Expand All @@ -82,57 +81,9 @@
disabled_tools = get_disabled_tools(config_path)
code_mode = "--code-mode" in sys.argv

# Validate config before branching so all paths share the same MCPConfig object
# for server-name lookups (e.g. --auth target validation).
config = MCPConfig.model_validate(mcp_dict)

if "--auth" in sys.argv:
# Resolve and validate the target server *before* creating the proxy so we
# can narrow the connection to only the requested server.
auth_idx = next(
(i for i, arg in enumerate(filtered_argv) if arg == "--auth"), None
)
if auth_idx is not None:
target = next(
(
filtered_argv[i]
for i in range(auth_idx + 1, len(filtered_argv))
if not filtered_argv[i].startswith("-")
),
None,
)
else:
target = None
if target and target not in config.mcpServers:
print(
f"Unknown server '{target}'. Available: {', '.join(config.mcpServers)}"
)
sys.exit(1)

if target:
# Connect only to the requested server instead of all of them.
narrow_dict = {**mcp_dict, "mcpServers": {target: mcp_dict["mcpServers"][target]}}
auth_config = MCPConfig.model_validate(narrow_dict)
configure_servers(auth_config)
mcp = create_proxy(auth_config, name="jarvis")
else:
configure_servers(config)
mcp = create_proxy(config, name="jarvis")

mcp.add_transform(BM25SearchTransform(max_results=5))

async def auth() -> None:
tools = await mcp.list_tools()
print(f"Authenticated. {len(tools)} tools available:")
for t in tools:
print(f" - {t.name}")

try:
asyncio.run(auth())
except KeyboardInterrupt:
print("\nAuth cancelled.")

elif "--http" in sys.argv:
if "--http" in sys.argv:
idx = sys.argv.index("--http")
port_arg = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else ""
if not port_arg.isdigit():
Expand All @@ -148,12 +99,10 @@ async def auth() -> None:
port = parsed_port

configure_servers(config)
mcp = create_proxy(config, name="jarvis")
mcp = build_proxy(config, "jarvis")
if disabled_tools:
mcp.disable(names=disabled_tools)
mcp.add_transform(
CodeMode() if code_mode else BM25SearchTransform(max_results=5)
)
mcp.add_transform(CodeMode() if code_mode else BM25SearchTransform(max_results=5))

async def _run_http() -> None:
start_api_thread(port, port + 1)
Expand All @@ -168,10 +117,8 @@ async def _run_http() -> None:

else:
configure_servers(config)
mcp = create_proxy(config, name="jarvis")
mcp = build_proxy(config, "jarvis")
if disabled_tools:
mcp.disable(names=disabled_tools)
mcp.add_transform(
CodeMode() if code_mode else BM25SearchTransform(max_results=5)
)
mcp.add_transform(CodeMode() if code_mode else BM25SearchTransform(max_results=5))
mcp.run(show_banner=False)
4 changes: 3 additions & 1 deletion src/jarvis/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@

ENV_VAR_RE = re.compile(r"\$\{(\w+)\}")
NON_STANDARD_KEYS = {"enabled", "disabledTools"}
DATA_DIR = Path.home() / ".jarvis"
# Data directory is overridable via ``JARVIS_DATA_DIR`` so tests (and alternate
# deployments) can isolate their state from the user's real ``~/.jarvis``.
DATA_DIR = Path(os.environ.get("JARVIS_DATA_DIR") or (Path.home() / ".jarvis"))
Comment thread
ArtemisMucaj marked this conversation as resolved.
PRESETS_PATH = DATA_DIR / "presets.json"
token_storage = DiskStore(directory=str(DATA_DIR))

Expand Down
56 changes: 56 additions & 0 deletions src/jarvis/proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Proxy builder for Jarvis.

Replaces ``fastmcp.server.create_proxy(MCPConfig)`` with a builder that uses
``StatefulProxyClient`` for stdio backends (persistent subprocess per frontend
session) and ``ProxyClient`` for HTTP/SSE backends (fresh connection per request).
"""

from __future__ import annotations

from fastmcp.mcp_config import MCPConfig, StdioMCPServer
from fastmcp.server import FastMCP
from fastmcp.server.providers.proxy import (
ProxyClient,
ProxyProvider,
StatefulProxyClient,
)


def build_proxy(config: MCPConfig, name: str = "jarvis") -> FastMCP:
"""Build a FastMCP proxy server from an MCPConfig.

For each server in *config*:
- stdio servers get a ``StatefulProxyClient`` with ``new_stateful`` as the
client factory, so the subprocess lives for the duration of each frontend
session rather than being respawned on every tool call.
- HTTP/SSE servers get a ``ProxyClient`` with ``new`` as the factory,
giving a fresh connection per request (stateless, correct for HTTP).

Args:
config: Validated MCPConfig with servers already configured
(OAuth injected, env vars expanded).
name: Name for the resulting FastMCP server.

Returns:
A ``FastMCP`` server with one ``ProxyProvider`` per backend, namespaced
by server name.
"""
mcp: FastMCP = FastMCP(name=name)
# Keep strong references to StatefulProxyClient instances so they are not
# garbage-collected while the server is alive (new_stateful reads _caches).
mcp._stateful_clients: list = [] # type: ignore[attr-defined]

for server_name, server in config.mcpServers.items():
transport = server.to_transport()

if isinstance(server, StdioMCPServer):
client = StatefulProxyClient(transport)
mcp._stateful_clients.append(client)
factory = client.new_stateful
else:
client = ProxyClient(transport)
factory = client.new

mcp.add_provider(ProxyProvider(factory), namespace=server_name)

return mcp
10 changes: 6 additions & 4 deletions src/jarvis/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ def load_config(config_path: Path) -> tuple[dict[str, Any], str | None]:
return {"mcpServers": {}}, f"Config parse error: {exc}"



# ── MCP Manager ───────────────────────────────────────────────────────────────


Expand Down Expand Up @@ -192,7 +191,10 @@ async def _probe_all(self) -> None:
raw_servers = {
d["name"]: servers_config[d["name"]]
for node in tree.root.children
if (d := node.data) and d.get("type") == "server" and d.get("enabled", True) and d["name"] in servers_config
if (d := node.data)
and d.get("type") == "server"
and d.get("enabled", True)
and d["name"] in servers_config
}

total = len(raw_servers)
Expand Down Expand Up @@ -285,8 +287,8 @@ class AuthManagerApp(App[None]):
"""Manage OAuth authentication for proxied MCP servers.

Lists every configured server and its auth type. For OAuth servers the
user can trigger a login flow (opens the browser via the existing
``jarvis --auth SERVER`` flow) or clear all cached tokens.
user can trigger a login flow (opens the browser) or clear all cached
tokens.
"""

TITLE = "Jarvis Auth Manager"
Expand Down
Empty file added tests/__init__.py
Empty file.
Loading
Loading