localmcplib 0.1.3__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.
localmcp/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """Reusable foundations for local MCP servers.
2
+
3
+ Optional capabilities are resolved lazily so importing :mod:`localmcp` alone
4
+ does not require an LLM, MCP, telemetry, or workflow stack.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from localmcp.server import STDIOServer as STDIOServer
13
+ from localmcp.server import main as main
14
+
15
+ __all__ = ["STDIOServer", "main"]
16
+
17
+ __version__ = "0.1.3"
18
+
19
+
20
+ def __getattr__(name: str) -> Any:
21
+ if name == "STDIOServer":
22
+ from localmcp.server import STDIOServer
23
+
24
+ return STDIOServer
25
+ if name == "main":
26
+ from localmcp.server import main
27
+
28
+ return main
29
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
localmcp/config.py ADDED
@@ -0,0 +1,233 @@
1
+ """One XDG-compliant, pyproject-like configuration document."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import os
7
+ import re
8
+ import tomllib
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ _IDENTIFIER = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9])?$")
15
+ _SCHEMA_VERSION = 1
16
+
17
+
18
+ class ConfigError(ValueError):
19
+ """Raised when the local MCP document cannot be resolved."""
20
+
21
+
22
+ def validate_identifier(value: str, *, field: str) -> str:
23
+ if not _IDENTIFIER.fullmatch(value) or ".." in value:
24
+ raise ConfigError(
25
+ f"{field} must be 1-128 letters, numbers, dots, underscores, or dashes and must not contain '..'"
26
+ )
27
+ return value
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class XDGDirectories:
32
+ config: Path
33
+ state: Path
34
+ cache: Path
35
+
36
+ @classmethod
37
+ def from_environment(
38
+ cls,
39
+ env: Mapping[str, str] | None = None,
40
+ *,
41
+ home: Path | None = None,
42
+ ) -> XDGDirectories:
43
+ source = os.environ if env is None else env
44
+ resolved_home = Path.home() if home is None else home
45
+ return cls(
46
+ config=_base_path(source.get("XDG_CONFIG_HOME"), resolved_home / ".config"),
47
+ state=_base_path(source.get("XDG_STATE_HOME"), resolved_home / ".local" / "state"),
48
+ cache=_base_path(source.get("XDG_CACHE_HOME"), resolved_home / ".cache"),
49
+ )
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class LocalMCPPaths:
54
+ """Shared document paths plus isolated per-server state paths."""
55
+
56
+ xdg: XDGDirectories
57
+ root_name: str = "localmcp"
58
+
59
+ def __post_init__(self) -> None:
60
+ validate_identifier(self.root_name, field="root name")
61
+
62
+ @classmethod
63
+ def from_environment(
64
+ cls,
65
+ env: Mapping[str, str] | None = None,
66
+ *,
67
+ home: Path | None = None,
68
+ root_name: str = "localmcp",
69
+ ) -> LocalMCPPaths:
70
+ return cls(XDGDirectories.from_environment(env, home=home), root_name=root_name)
71
+
72
+ @property
73
+ def config_dir(self) -> Path:
74
+ return self.xdg.config / self.root_name
75
+
76
+ @property
77
+ def config_file(self) -> Path:
78
+ return self.config_dir / "localmcp.toml"
79
+
80
+ @property
81
+ def state_dir(self) -> Path:
82
+ return self.xdg.state / self.root_name
83
+
84
+ @property
85
+ def cache_dir(self) -> Path:
86
+ return self.xdg.cache / self.root_name
87
+
88
+ def server_state_dir(self, server: str) -> Path:
89
+ validate_identifier(server, field="server")
90
+ return self.state_dir / server
91
+
92
+ def server_cache_dir(self, server: str) -> Path:
93
+ validate_identifier(server, field="server")
94
+ return self.cache_dir / server
95
+
96
+ def server_log_file(self, server: str) -> Path:
97
+ validate_identifier(server, field="server")
98
+ return self.server_state_dir(server) / "logs" / f"{server}.log"
99
+
100
+
101
+ def _base_path(value: str | None, fallback: Path) -> Path:
102
+ if value and value.strip():
103
+ candidate = Path(value)
104
+ if candidate.is_absolute():
105
+ return candidate
106
+ return fallback
107
+
108
+
109
+ def read_toml(path: Path, *, missing_ok: bool = True) -> dict[str, Any]:
110
+ try:
111
+ with path.open("rb") as handle:
112
+ value = tomllib.load(handle)
113
+ except FileNotFoundError:
114
+ if missing_ok:
115
+ return {}
116
+ raise ConfigError(f"configuration file does not exist: {path}") from None
117
+ except (OSError, tomllib.TOMLDecodeError) as exc:
118
+ raise ConfigError(f"failed to read configuration file {path}: {exc}") from exc
119
+ return value
120
+
121
+
122
+ def merge_mappings(*layers: Mapping[str, Any]) -> dict[str, Any]:
123
+ """Recursively merge mappings; later scalars and lists replace earlier ones."""
124
+ merged: dict[str, Any] = {}
125
+ for layer in layers:
126
+ _merge_into(merged, layer)
127
+ return merged
128
+
129
+
130
+ def _merge_into(target: dict[str, Any], layer: Mapping[str, Any]) -> None:
131
+ for key, value in layer.items():
132
+ existing = target.get(key)
133
+ if isinstance(existing, dict) and isinstance(value, Mapping):
134
+ _merge_into(existing, value)
135
+ else:
136
+ target[key] = copy.deepcopy(value)
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class ServerConfig:
141
+ """The effective global-plus-server view for one MCP server."""
142
+
143
+ name: str
144
+ values: dict[str, Any]
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class ConfigDocument:
149
+ """Parsed `localmcp.toml` with per-MCP-server composition.
150
+
151
+ All root tables except ``server`` are global defaults. A
152
+ ``[server.<name>...]`` tree deep-merges over those defaults for that server.
153
+ Unknown application keys are deliberately retained for the consuming
154
+ application's schema to validate.
155
+ """
156
+
157
+ path: Path
158
+ values: dict[str, Any]
159
+
160
+ @classmethod
161
+ def load(
162
+ cls,
163
+ *,
164
+ config_path: Path | None = None,
165
+ env: Mapping[str, str] | None = None,
166
+ home: Path | None = None,
167
+ missing_ok: bool = True,
168
+ ) -> ConfigDocument:
169
+ resolved = config_path or LocalMCPPaths.from_environment(env, home=home).config_file
170
+ values = read_toml(resolved, missing_ok=missing_ok)
171
+ if "tool" in values:
172
+ raise ConfigError("root 'tool' table is no longer supported; use 'server' instead")
173
+ version = values.get("schema_version", _SCHEMA_VERSION)
174
+ if isinstance(version, bool) or not isinstance(version, int):
175
+ raise ConfigError("schema_version must be an integer")
176
+ if version != _SCHEMA_VERSION:
177
+ raise ConfigError(f"unsupported localmcp schema_version {version}; expected {_SCHEMA_VERSION}")
178
+ server_table = values.get("server", {})
179
+ if not isinstance(server_table, dict):
180
+ raise ConfigError("server must be a TOML table")
181
+ return cls(path=resolved, values=values)
182
+
183
+ @property
184
+ def global_values(self) -> dict[str, Any]:
185
+ return {key: copy.deepcopy(value) for key, value in self.values.items() if key != "server"}
186
+
187
+ @property
188
+ def server_names(self) -> tuple[str, ...]:
189
+ servers = self.values.get("server", {})
190
+ assert isinstance(servers, dict)
191
+ return tuple(sorted(servers))
192
+
193
+ def for_server(self, name: str) -> ServerConfig:
194
+ validate_identifier(name, field="server")
195
+ servers = self.values.get("server", {})
196
+ assert isinstance(servers, dict)
197
+ overlay = servers.get(name, {})
198
+ if not isinstance(overlay, Mapping):
199
+ raise ConfigError(f"server.{name} must be a TOML table")
200
+ return ServerConfig(name=name, values=merge_mappings(self.global_values, overlay))
201
+
202
+ def secret_mappings(self, server: str | None = None) -> dict[str, dict[str, Any]]:
203
+ """Return composed secret declarations without resolving any values."""
204
+ global_secrets = _mapping_table(self.global_values.get("secrets", {}), field="secrets")
205
+ composed = {name: copy.deepcopy(dict(value)) for name, value in global_secrets.items()}
206
+ if server is None:
207
+ return composed
208
+
209
+ validate_identifier(server, field="server")
210
+ servers = self.values.get("server", {})
211
+ assert isinstance(servers, dict)
212
+ raw_server = servers.get(server, {})
213
+ if not isinstance(raw_server, Mapping):
214
+ raise ConfigError(f"server.{server} must be a TOML table")
215
+ server_secrets = _mapping_table(raw_server.get("secrets", {}), field=f"server.{server}.secrets")
216
+ for name, value in server_secrets.items():
217
+ if name in composed:
218
+ composed[name] = merge_mappings(composed[name], value)
219
+ else:
220
+ composed[name] = copy.deepcopy(dict(value))
221
+ return composed
222
+
223
+
224
+ def _mapping_table(value: Any, *, field: str) -> dict[str, Mapping[str, Any]]:
225
+ if not isinstance(value, Mapping):
226
+ raise ConfigError(f"{field} must be a TOML table")
227
+ result: dict[str, Mapping[str, Any]] = {}
228
+ for name, item in value.items():
229
+ validate_identifier(str(name), field=f"{field} name")
230
+ if not isinstance(item, Mapping):
231
+ raise ConfigError(f"{field}.{name} must be a TOML table")
232
+ result[str(name)] = item
233
+ return result