obsidian-cli-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- obsidian_cli_mcp/__init__.py +31 -0
- obsidian_cli_mcp/cli.py +38 -0
- obsidian_cli_mcp/client.py +111 -0
- obsidian_cli_mcp/exceptions.py +22 -0
- obsidian_cli_mcp/py.typed +0 -0
- obsidian_cli_mcp/server.py +20 -0
- obsidian_cli_mcp/tools.py +1238 -0
- obsidian_cli_mcp-0.1.0.dist-info/METADATA +288 -0
- obsidian_cli_mcp-0.1.0.dist-info/RECORD +11 -0
- obsidian_cli_mcp-0.1.0.dist-info/WHEEL +4 -0
- obsidian_cli_mcp-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from .client import ObsidianClient
|
|
5
|
+
from .exceptions import ObsidianCLIError, ObsidianError, ObsidianNotFoundError
|
|
6
|
+
from .server import create_server
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ObsidianCLIError",
|
|
10
|
+
"ObsidianClient",
|
|
11
|
+
"ObsidianError",
|
|
12
|
+
"ObsidianNotFoundError",
|
|
13
|
+
"create_server",
|
|
14
|
+
"main",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _run_server() -> None:
|
|
19
|
+
try:
|
|
20
|
+
server = create_server()
|
|
21
|
+
await server.run_stdio_async()
|
|
22
|
+
except ObsidianNotFoundError as e:
|
|
23
|
+
print(f"Error starting obsidian-mcp: {e}", file=sys.stderr)
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
except KeyboardInterrupt:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> None:
|
|
30
|
+
"""CLI 入口点,启动 MCP Stdio 通信服务。"""
|
|
31
|
+
asyncio.run(_run_server())
|
obsidian_cli_mcp/cli.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class CommandExecutionResult:
|
|
7
|
+
returncode: int | None
|
|
8
|
+
stdout: str
|
|
9
|
+
stderr: str
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def run_cli_command(
|
|
13
|
+
program: str, args: list[str], *, timeout: int = 30
|
|
14
|
+
) -> CommandExecutionResult:
|
|
15
|
+
"""运行 CLI 子进程命令并等待输出。"""
|
|
16
|
+
try:
|
|
17
|
+
process = await asyncio.create_subprocess_exec(
|
|
18
|
+
program,
|
|
19
|
+
*args,
|
|
20
|
+
stdout=asyncio.subprocess.PIPE,
|
|
21
|
+
stderr=asyncio.subprocess.PIPE,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
|
25
|
+
|
|
26
|
+
return CommandExecutionResult(
|
|
27
|
+
returncode=process.returncode,
|
|
28
|
+
stdout=stdout.decode("utf-8", errors="replace").strip(),
|
|
29
|
+
stderr=stderr.decode("utf-8", errors="replace").strip(),
|
|
30
|
+
)
|
|
31
|
+
except TimeoutError:
|
|
32
|
+
process.kill()
|
|
33
|
+
await process.wait()
|
|
34
|
+
return CommandExecutionResult(
|
|
35
|
+
returncode=-1,
|
|
36
|
+
stdout="",
|
|
37
|
+
stderr=f"Command timed out after {timeout} seconds",
|
|
38
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .cli import CommandExecutionResult, run_cli_command
|
|
7
|
+
from .exceptions import ObsidianCLIError, ObsidianNotFoundError
|
|
8
|
+
|
|
9
|
+
# 允许保留空字符串的参数(如清空属性值、创建空白内容等)
|
|
10
|
+
ALLOW_EMPTY_VALUE_KEYS = {"content", "value"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ObsidianClient:
|
|
14
|
+
"""Obsidian CLI 异步客户端。负责参数序列化、环境变量回退、执行控制与结果解析。"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
vault: str | None = None,
|
|
19
|
+
cli_path: str | None = None,
|
|
20
|
+
timeout: int = 30,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.vault = vault or os.getenv("OBSIDIAN_VAULT")
|
|
23
|
+
resolved_cli = (
|
|
24
|
+
cli_path or os.getenv("OBSIDIAN_CLI_PATH") or shutil.which("obsidian")
|
|
25
|
+
)
|
|
26
|
+
if not resolved_cli:
|
|
27
|
+
raise ObsidianNotFoundError(
|
|
28
|
+
"Obsidian CLI executable not found. Please install Obsidian CLI or set OBSIDIAN_CLI_PATH."
|
|
29
|
+
)
|
|
30
|
+
self.cli_path = resolved_cli
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
|
|
33
|
+
def _build_args(
|
|
34
|
+
self,
|
|
35
|
+
command: str,
|
|
36
|
+
params: dict[str, Any] | None = None,
|
|
37
|
+
vault: str | None = None,
|
|
38
|
+
) -> list[str]:
|
|
39
|
+
"""将 Python 字典转换为 Obsidian CLI 参数列表。
|
|
40
|
+
|
|
41
|
+
规则:
|
|
42
|
+
- True: 转换为裸 flag(例如 verbose=True -> 'verbose')
|
|
43
|
+
- False / None: 忽略该参数
|
|
44
|
+
- 纯空白字符串(若非 content/value 白名单): 忽略该参数,防止意外触发 CLI 对活跃文件的回退行为
|
|
45
|
+
- 其余类型: 转换为 key=value 字符串
|
|
46
|
+
"""
|
|
47
|
+
args = [command]
|
|
48
|
+
merged_params = dict(params or {})
|
|
49
|
+
|
|
50
|
+
# 确定目标 vault:参数指定 > 实例/环境变量默认 > 不传(回退到活跃库)
|
|
51
|
+
target_vault = vault if vault is not None else self.vault
|
|
52
|
+
if target_vault and target_vault.strip() and "vault" not in merged_params:
|
|
53
|
+
merged_params["vault"] = target_vault.strip()
|
|
54
|
+
|
|
55
|
+
for key, val in merged_params.items():
|
|
56
|
+
if val is None or val is False:
|
|
57
|
+
continue
|
|
58
|
+
if val is True:
|
|
59
|
+
args.append(key)
|
|
60
|
+
elif (
|
|
61
|
+
isinstance(val, str)
|
|
62
|
+
and not val.strip()
|
|
63
|
+
and key not in ALLOW_EMPTY_VALUE_KEYS
|
|
64
|
+
):
|
|
65
|
+
continue
|
|
66
|
+
else:
|
|
67
|
+
args.append(f"{key}={val}")
|
|
68
|
+
|
|
69
|
+
return args
|
|
70
|
+
|
|
71
|
+
async def execute(
|
|
72
|
+
self,
|
|
73
|
+
command: str,
|
|
74
|
+
params: dict[str, Any] | None = None,
|
|
75
|
+
*,
|
|
76
|
+
parse_json: bool = False,
|
|
77
|
+
vault: str | None = None,
|
|
78
|
+
timeout: int | None = None,
|
|
79
|
+
) -> Any:
|
|
80
|
+
"""执行 Obsidian CLI 命令并返回文本或解析后的 JSON。"""
|
|
81
|
+
cli_args = self._build_args(command, params, vault=vault)
|
|
82
|
+
res: CommandExecutionResult = await run_cli_command(
|
|
83
|
+
self.cli_path, cli_args, timeout=timeout or self.timeout
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# 检查错误:非零退出码或 stdout 以 Error: 开头
|
|
87
|
+
if res.returncode != 0:
|
|
88
|
+
err_msg = (
|
|
89
|
+
res.stderr
|
|
90
|
+
or res.stdout
|
|
91
|
+
or f"Command '{command}' failed with exit code {res.returncode}"
|
|
92
|
+
)
|
|
93
|
+
raise ObsidianCLIError(
|
|
94
|
+
err_msg, returncode=res.returncode, stderr=res.stderr, stdout=res.stdout
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if res.stdout.startswith("Error:"):
|
|
98
|
+
raise ObsidianCLIError(
|
|
99
|
+
res.stdout,
|
|
100
|
+
returncode=res.returncode,
|
|
101
|
+
stderr=res.stderr,
|
|
102
|
+
stdout=res.stdout,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if parse_json:
|
|
106
|
+
try:
|
|
107
|
+
return json.loads(res.stdout)
|
|
108
|
+
except json.JSONDecodeError:
|
|
109
|
+
return res.stdout
|
|
110
|
+
|
|
111
|
+
return res.stdout
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class ObsidianError(Exception):
|
|
2
|
+
"""Base exception for obsidian-mcp."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ObsidianNotFoundError(ObsidianError):
|
|
6
|
+
"""Raised when obsidian CLI executable is not found."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ObsidianCLIError(ObsidianError):
|
|
10
|
+
"""Raised when obsidian CLI command execution fails."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
message: str,
|
|
15
|
+
returncode: int | None = None,
|
|
16
|
+
stderr: str = "",
|
|
17
|
+
stdout: str = "",
|
|
18
|
+
) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.returncode = returncode
|
|
21
|
+
self.stderr = stderr
|
|
22
|
+
self.stdout = stdout
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from mcp.server.mcpserver import MCPServer
|
|
2
|
+
|
|
3
|
+
from .client import ObsidianClient
|
|
4
|
+
from .tools import register_tools
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create_server(
|
|
8
|
+
vault: str | None = None,
|
|
9
|
+
cli_path: str | None = None,
|
|
10
|
+
timeout: int = 30,
|
|
11
|
+
) -> MCPServer:
|
|
12
|
+
"""创建并配置 Obsidian MCP 服务器实例。"""
|
|
13
|
+
client = ObsidianClient(vault=vault, cli_path=cli_path, timeout=timeout)
|
|
14
|
+
server = MCPServer(
|
|
15
|
+
name="obsidian-cli-mcp",
|
|
16
|
+
version="0.1.0",
|
|
17
|
+
instructions="Obsidian CLI MCP server providing full control over Obsidian vaults.",
|
|
18
|
+
)
|
|
19
|
+
register_tools(server, client)
|
|
20
|
+
return server
|