Skip to content

Commit 82855dc

Browse files
committed
Add MCP server mode for AI assistant integration
Expose all plugin commands as MCP tools over stdio transport. AI assistants (Claude Desktop, Claude Code) can now use Nakimi natively via the Model Context Protocol. - nakimi serve: starts MCP server (stdio transport) - nakimi-mcp: direct entry point for Claude Desktop config - mcp is an optional dependency (pip install nakimi[mcp]) - CI installs mcp on Python 3.10+ to run MCP tests - 16 new unit tests, all passing
1 parent 331d54e commit 82855dc

File tree

5 files changed

+383
-6
lines changed

5 files changed

+383
-6
lines changed

.github/workflows/test.yml

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,15 @@ jobs:
3838
run: |
3939
python -m pip install --upgrade pip
4040
pip install -e .[dev]
41-
41+
42+
- name: Install MCP dependencies (Python 3.10+)
43+
if: matrix.python-version != '3.9'
44+
run: pip install -e .[mcp]
45+
4246
- name: Run unit tests
4347
run: |
4448
python -m pytest tests/unit/ --cov=src/nakimi --cov-report=term-missing --cov-report=xml
45-
49+
4650
- name: Upload coverage to Codecov
4751
uses: codecov/codecov-action@v4
4852
with:
@@ -55,27 +59,31 @@ jobs:
5559
strategy:
5660
matrix:
5761
python-version: ["3.9", "3.10", "3.11", "3.12"]
58-
62+
5963
steps:
6064
- uses: actions/checkout@v4
61-
65+
6266
- name: Set up Python ${{ matrix.python-version }}
6367
uses: actions/setup-python@v5
6468
with:
6569
python-version: ${{ matrix.python-version }}
66-
70+
6771
- name: Install age encryption tool
6872
run: |
6973
wget -q https://github.com/FiloSottile/age/releases/download/v1.2.0/age-v1.2.0-darwin-amd64.tar.gz
7074
tar -xzf age-v1.2.0-darwin-amd64.tar.gz
7175
sudo mv age/age /usr/local/bin/
7276
sudo mv age/age-keygen /usr/local/bin/
7377
rm -rf age age-v1.2.0-darwin-amd64.tar.gz
74-
78+
7579
- name: Install Python dependencies
7680
run: |
7781
python -m pip install --upgrade pip
7882
pip install -e .[dev]
83+
84+
- name: Install MCP dependencies (Python 3.10+)
85+
if: matrix.python-version != '3.9'
86+
run: pip install -e .[mcp]
7987

8088
- name: Run unit tests
8189
run: |

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,13 @@ dev = [
4545
yubikey = [
4646
"yubikey-manager>=5.0.0",
4747
]
48+
mcp = [
49+
"mcp>=1.0.0",
50+
]
4851

4952
[project.scripts]
5053
nakimi = "nakimi.cli.main:main"
54+
nakimi-mcp = "nakimi.mcp_server:run_server"
5155

5256
[project.urls]
5357
Homepage = "https://github.com/apitanga/nakimi"

src/nakimi/cli/main.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,17 @@ def cmd_run(args):
184184
sys.exit(1)
185185

186186

187+
def cmd_serve(args):
188+
"""Start MCP server for AI assistant integration"""
189+
try:
190+
from nakimi.mcp_server import run_server
191+
except ImportError:
192+
print("MCP server requires the 'mcp' package.")
193+
print("Install with: pip install nakimi[mcp]")
194+
sys.exit(1)
195+
run_server()
196+
197+
187198
def cmd_upgrade(args):
188199
"""Upgrade nakimi to latest version from GitHub"""
189200
repo_url = "https://github.com/apitanga/nakimi.git"
@@ -603,6 +614,9 @@ class Args:
603614
change_parser.add_argument("old_pin", help="Current PIN")
604615
change_parser.add_argument("new_pin", help="New PIN")
605616

617+
# serve command (MCP server)
618+
subparsers.add_parser("serve", help="Start MCP server for AI assistant integration")
619+
606620
# upgrade command
607621
upgrade_parser = subparsers.add_parser("upgrade", help="Upgrade to latest version from GitHub")
608622
upgrade_parser.add_argument(
@@ -631,6 +645,7 @@ class Args:
631645
"plugins": cmd_plugins,
632646
"session": cmd_session,
633647
"upgrade": cmd_upgrade,
648+
"serve": cmd_serve,
634649
"yubikey": cmd_yubikey,
635650
}
636651

src/nakimi/mcp_server.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""
2+
MCP Server for Nakimi.
3+
4+
Exposes all plugin commands as MCP tools over stdio transport.
5+
Usage: nakimi serve
6+
"""
7+
8+
import logging
9+
import sys
10+
from typing import Any
11+
12+
import anyio
13+
import mcp.types as types
14+
from mcp.server.lowlevel import Server
15+
from mcp.server.stdio import stdio_server
16+
17+
from nakimi.core.plugin import PluginCommand, PluginError, PluginManager
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
def plugin_command_to_input_schema(cmd: PluginCommand) -> dict:
23+
"""Convert PluginCommand.args to JSON Schema for MCP inputSchema."""
24+
properties = {}
25+
required = []
26+
for arg_name, help_text, is_required in cmd.args:
27+
properties[arg_name] = {
28+
"type": "string",
29+
"description": help_text,
30+
}
31+
if is_required:
32+
required.append(arg_name)
33+
34+
schema: dict[str, Any] = {
35+
"type": "object",
36+
"properties": properties,
37+
}
38+
if required:
39+
schema["required"] = required
40+
return schema
41+
42+
43+
def tool_name_from_command(full_command: str) -> str:
44+
"""Convert 'gmail.unread' to 'gmail_unread' (dots not allowed in MCP tool names)."""
45+
return full_command.replace(".", "_")
46+
47+
48+
def command_from_tool_name(tool_name: str) -> str:
49+
"""Convert 'gmail_unread' back to 'gmail.unread'."""
50+
return tool_name.replace("_", ".", 1)
51+
52+
53+
def build_tools(plugin_manager: PluginManager) -> list[types.Tool]:
54+
"""Build MCP Tool list from all registered plugin commands."""
55+
tools = []
56+
for full_command in plugin_manager.list_commands():
57+
_, cmd = plugin_manager._commands[full_command]
58+
tool = types.Tool(
59+
name=tool_name_from_command(full_command),
60+
description=cmd.description,
61+
inputSchema=plugin_command_to_input_schema(cmd),
62+
)
63+
tools.append(tool)
64+
return tools
65+
66+
67+
def create_server(plugin_manager: PluginManager) -> Server:
68+
"""Create and configure the MCP server with all plugin tools."""
69+
server = Server("nakimi")
70+
71+
@server.list_tools()
72+
async def list_tools() -> list[types.Tool]:
73+
return build_tools(plugin_manager)
74+
75+
@server.call_tool()
76+
async def call_tool(
77+
name: str, arguments: dict[str, Any] | None
78+
) -> list[types.TextContent]:
79+
full_command = command_from_tool_name(name)
80+
args_dict = arguments or {}
81+
82+
try:
83+
if full_command not in plugin_manager._commands:
84+
raise PluginError(f"Unknown command: {full_command}")
85+
86+
_, cmd = plugin_manager._commands[full_command]
87+
88+
result = await anyio.to_thread.run_sync(
89+
lambda: cmd.handler(**{k: v for k, v in args_dict.items()})
90+
)
91+
92+
text = str(result) if result else "OK"
93+
return [types.TextContent(type="text", text=text)]
94+
95+
except PluginError as e:
96+
return [types.TextContent(type="text", text=f"Error: {e}")]
97+
except Exception as e:
98+
logger.exception("Unexpected error executing %s", full_command)
99+
return [types.TextContent(type="text", text=f"Error: {e}")]
100+
101+
return server
102+
103+
104+
async def run_async():
105+
"""Main async entry point for the MCP server."""
106+
from nakimi.cli.main import load_secrets
107+
108+
logger.info("Loading secrets...")
109+
try:
110+
secrets = load_secrets()
111+
except PluginError as e:
112+
print(f"Failed to load secrets: {e}", file=sys.stderr)
113+
sys.exit(1)
114+
115+
manager = PluginManager(secrets)
116+
manager.discover_plugins()
117+
118+
loaded = manager.list_plugins()
119+
if not loaded:
120+
print(
121+
"Warning: No plugins loaded. Check your secrets configuration.",
122+
file=sys.stderr,
123+
)
124+
else:
125+
print(f"Loaded plugins: {', '.join(loaded)}", file=sys.stderr)
126+
print(
127+
f"Available tools: {', '.join(manager.list_commands())}",
128+
file=sys.stderr,
129+
)
130+
131+
server = create_server(manager)
132+
133+
async with stdio_server() as (read_stream, write_stream):
134+
await server.run(
135+
read_stream,
136+
write_stream,
137+
server.create_initialization_options(),
138+
)
139+
140+
141+
def run_server():
142+
"""Synchronous entry point for MCP server."""
143+
logging.basicConfig(
144+
level=logging.INFO,
145+
format="%(levelname)s: %(message)s",
146+
stream=sys.stderr,
147+
)
148+
anyio.run(run_async)

0 commit comments

Comments
 (0)