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 +29 -0
- localmcp/config.py +233 -0
- localmcp/llm.py +575 -0
- localmcp/observability/__init__.py +1 -0
- localmcp/observability/logging.py +160 -0
- localmcp/observability/telemetry.py +331 -0
- localmcp/py.typed +1 -0
- localmcp/sandbox/__init__.py +29 -0
- localmcp/sandbox/base.py +69 -0
- localmcp/sandbox/seatbelt.py +455 -0
- localmcp/secrets.py +194 -0
- localmcp/server.py +315 -0
- localmcp/workflows/__init__.py +1 -0
- localmcp/workflows/catalog.py +321 -0
- localmcp/workflows/runtime.py +279 -0
- localmcplib-0.1.3.dist-info/METADATA +247 -0
- localmcplib-0.1.3.dist-info/RECORD +19 -0
- localmcplib-0.1.3.dist-info/WHEEL +4 -0
- localmcplib-0.1.3.dist-info/licenses/LICENSE.txt +207 -0
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
|