yamcps 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.
- yamcps/__init__.py +16 -0
- yamcps/__main__.py +3 -0
- yamcps/cli.py +82 -0
- yamcps/connectors.py +99 -0
- yamcps/onboarding.py +182 -0
- yamcps/py.typed +0 -0
- yamcps/registry.py +37 -0
- yamcps/secrets.py +167 -0
- yamcps/server.py +72 -0
- yamcps/skills.py +110 -0
- yamcps/vault.py +184 -0
- yamcps-0.1.0.dist-info/METADATA +67 -0
- yamcps-0.1.0.dist-info/RECORD +16 -0
- yamcps-0.1.0.dist-info/WHEEL +4 -0
- yamcps-0.1.0.dist-info/entry_points.txt +2 -0
- yamcps-0.1.0.dist-info/licenses/LICENSE +21 -0
yamcps/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Yet Another Model Context Protocole Server public connector and credential contracts."""
|
|
2
|
+
|
|
3
|
+
from yamcps.connectors import Connector, SecretConsumer, SecretRequirements, SkillProvider
|
|
4
|
+
from yamcps.secrets import BasicAuth, ConnectorSecrets, SecretError, SecretSpec, TokenAuth
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Connector",
|
|
8
|
+
"SecretConsumer",
|
|
9
|
+
"SecretRequirements",
|
|
10
|
+
"SecretSpec",
|
|
11
|
+
"SkillProvider",
|
|
12
|
+
"ConnectorSecrets",
|
|
13
|
+
"SecretError",
|
|
14
|
+
"TokenAuth",
|
|
15
|
+
"BasicAuth",
|
|
16
|
+
]
|
yamcps/__main__.py
ADDED
yamcps/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""yamcps command line; stdout is reserved for stdio MCP traffic."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from yamcps.connectors import ConnectorError, discover, load
|
|
9
|
+
from yamcps.onboarding import configure_secrets, default_vault, initialize_vault, requirements
|
|
10
|
+
from yamcps.secrets import SecretError, SecretSpec
|
|
11
|
+
from yamcps.server import create_server
|
|
12
|
+
from yamcps.vault import Vault
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _vault_options(parser: argparse.ArgumentParser) -> None:
|
|
16
|
+
parser.add_argument("--vault", type=Path)
|
|
17
|
+
parser.add_argument("--key-file", type=Path)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _select_vault(args: argparse.Namespace) -> Vault:
|
|
21
|
+
if bool(args.vault) != bool(args.key_file):
|
|
22
|
+
raise SecretError("--vault and --key-file must be supplied together")
|
|
23
|
+
return Vault(args.vault, args.key_file) if args.vault else default_vault()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _manage_secrets(args: argparse.Namespace) -> None:
|
|
27
|
+
vault = _select_vault(args)
|
|
28
|
+
if args.action == "init":
|
|
29
|
+
initialize_vault(vault)
|
|
30
|
+
elif args.action == "list":
|
|
31
|
+
for name in vault.names():
|
|
32
|
+
print(name)
|
|
33
|
+
elif args.action == "delete":
|
|
34
|
+
vault.delete(args.connector, args.name)
|
|
35
|
+
else:
|
|
36
|
+
specs = [SecretSpec(args.name)] if args.name else requirements(load([args.connector])[0])
|
|
37
|
+
configure_secrets(args.connector, specs, vault)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main() -> None:
|
|
41
|
+
"""Parse CLI commands and start the server or manage an encrypted vault."""
|
|
42
|
+
parser = argparse.ArgumentParser(prog="yamcps")
|
|
43
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
44
|
+
connectors = commands.add_parser("connectors").add_subparsers(dest="action", required=True)
|
|
45
|
+
connectors.add_parser("list")
|
|
46
|
+
secrets = commands.add_parser("secrets").add_subparsers(dest="action", required=True)
|
|
47
|
+
for action in ("init", "set", "delete", "list"):
|
|
48
|
+
subcommand = secrets.add_parser(action)
|
|
49
|
+
_vault_options(subcommand)
|
|
50
|
+
if action in ("set", "delete"):
|
|
51
|
+
subcommand.add_argument("--connector", required=True)
|
|
52
|
+
subcommand.add_argument("--name", required=action == "delete")
|
|
53
|
+
serve = commands.add_parser("serve")
|
|
54
|
+
serve.add_argument(
|
|
55
|
+
"--connector",
|
|
56
|
+
action="append",
|
|
57
|
+
help="Load only this connector (repeatable); defaults to all installed plugins",
|
|
58
|
+
)
|
|
59
|
+
serve.add_argument("--transport", choices=["stdio", "http"], default="stdio")
|
|
60
|
+
serve.add_argument("--port", type=int, default=8000)
|
|
61
|
+
_vault_options(serve)
|
|
62
|
+
args = parser.parse_args()
|
|
63
|
+
logging.basicConfig(level=logging.INFO)
|
|
64
|
+
try:
|
|
65
|
+
if args.command == "connectors":
|
|
66
|
+
for name, entry in sorted(discover().items()):
|
|
67
|
+
print(f"{name}\t{entry.value}")
|
|
68
|
+
return
|
|
69
|
+
if args.command == "secrets":
|
|
70
|
+
_manage_secrets(args)
|
|
71
|
+
return
|
|
72
|
+
if not 1 <= args.port <= 65535:
|
|
73
|
+
parser.error("--port must be between 1 and 65535")
|
|
74
|
+
server = create_server(
|
|
75
|
+
args.connector, vault=_select_vault(args), interactive=sys.stdin.isatty()
|
|
76
|
+
)
|
|
77
|
+
if args.transport == "stdio":
|
|
78
|
+
server.run(transport="stdio")
|
|
79
|
+
else:
|
|
80
|
+
server.run(transport="streamable-http", host="127.0.0.1", port=args.port)
|
|
81
|
+
except (ConnectorError, SecretError) as exc:
|
|
82
|
+
parser.exit(2, f"yamcps: {exc}\n")
|
yamcps/connectors.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Discover installed plugins without importing unselected connectors."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from importlib.metadata import EntryPoint, entry_points
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from mcp.server import MCPServer
|
|
11
|
+
|
|
12
|
+
from yamcps.secrets import ConnectorSecrets, SecretSpec
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Connector(Protocol):
|
|
16
|
+
id: str
|
|
17
|
+
|
|
18
|
+
def register(self, server: MCPServer) -> None: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SecretConsumer(Protocol):
|
|
22
|
+
"""Optional connector capability for receiving scoped API credentials."""
|
|
23
|
+
|
|
24
|
+
def configure(self, secrets: ConnectorSecrets) -> None:
|
|
25
|
+
"""Validate and retain credentials before registering MCP handlers.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
secrets: Credential view scoped to this connector.
|
|
29
|
+
"""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SecretRequirements(Protocol):
|
|
34
|
+
"""Optional declaration used by interactive secret configuration."""
|
|
35
|
+
|
|
36
|
+
def required_secrets(self) -> Iterable[SecretSpec]:
|
|
37
|
+
"""Describe the credentials required by this connector.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Required fields in the order they should be prompted.
|
|
41
|
+
"""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SkillProvider(Protocol):
|
|
46
|
+
def skill_roots(self) -> Iterable[Path]: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ConnectorError(ValueError):
|
|
50
|
+
"""A plugin cannot safely be loaded."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def discover() -> dict[str, EntryPoint]:
|
|
54
|
+
plugins: dict[str, EntryPoint] = {}
|
|
55
|
+
for entry in entry_points(group="yamcps.connectors"):
|
|
56
|
+
if entry.name in plugins:
|
|
57
|
+
raise ConnectorError(f"Duplicate connector entry point: {entry.name}")
|
|
58
|
+
plugins[entry.name] = entry
|
|
59
|
+
return plugins
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load(names: list[str] | None = None) -> list[Connector]:
|
|
63
|
+
"""Load selected connectors, or every installed connector when omitted.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
names: Explicit selection; None selects all entry points in name order.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Validated connector instances.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ConnectorError: Discovery/loading fails or automatic selection is empty.
|
|
73
|
+
"""
|
|
74
|
+
available = discover()
|
|
75
|
+
if names is None:
|
|
76
|
+
names = sorted(available)
|
|
77
|
+
if not names:
|
|
78
|
+
raise ConnectorError("No connectors installed; install an yamcps plugin first")
|
|
79
|
+
selected: list[Connector] = []
|
|
80
|
+
seen: set[str] = set()
|
|
81
|
+
for name in names:
|
|
82
|
+
try:
|
|
83
|
+
entry = available[name]
|
|
84
|
+
connector = entry.load()()
|
|
85
|
+
identifier = connector.id
|
|
86
|
+
if not isinstance(identifier, str) or not re.fullmatch(r"[a-z][a-z0-9-]*", identifier):
|
|
87
|
+
raise ValueError("id must be a lowercase kebab-case identifier")
|
|
88
|
+
if identifier != name:
|
|
89
|
+
raise ValueError(f"id {identifier!r} must match entry point {name!r}")
|
|
90
|
+
if identifier in seen:
|
|
91
|
+
raise ValueError(f"duplicate connector id: {identifier}")
|
|
92
|
+
register = getattr(connector, "register", None)
|
|
93
|
+
if not callable(register) or inspect.iscoroutinefunction(register):
|
|
94
|
+
raise ValueError("register must be a synchronous callable")
|
|
95
|
+
seen.add(identifier)
|
|
96
|
+
selected.append(connector)
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
raise ConnectorError(f"Connector {name!r}: {exc}") from exc
|
|
99
|
+
return selected
|
yamcps/onboarding.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Discover required credentials and collect missing values before MCP starts."""
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import inspect
|
|
5
|
+
import sys
|
|
6
|
+
import warnings
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from yamcps.connectors import Connector, ConnectorError
|
|
11
|
+
from yamcps.secrets import ConnectorSecrets, SecretError, SecretSpec, _identifier
|
|
12
|
+
from yamcps.vault import Vault
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_vault() -> Vault:
|
|
16
|
+
"""Locate the default local vault and separate master key.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
A vault below ~/.local/share/yamcps with a key below ~/.config/yamcps/keys.
|
|
20
|
+
"""
|
|
21
|
+
home = Path.home()
|
|
22
|
+
return Vault(
|
|
23
|
+
home / ".local/share/yamcps/secrets.yamcps-vault",
|
|
24
|
+
home / ".config/yamcps/keys/master.yamcps-key",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def requirements(connector: Connector) -> list[SecretSpec]:
|
|
29
|
+
"""Read a connector's optional credential declaration.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
connector: Loaded connector whose declaration is inspected once.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Ordered, unique required secret descriptions.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ConnectorError: The declaration is invalid or cannot be read.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
declare = getattr(connector, "required_secrets", None)
|
|
42
|
+
if declare is None:
|
|
43
|
+
return []
|
|
44
|
+
if not callable(declare) or inspect.iscoroutinefunction(declare):
|
|
45
|
+
raise TypeError
|
|
46
|
+
specs = list(declare())
|
|
47
|
+
seen: set[str] = set()
|
|
48
|
+
for spec in specs:
|
|
49
|
+
if not isinstance(spec, SecretSpec) or spec.name in seen:
|
|
50
|
+
raise TypeError
|
|
51
|
+
seen.add(spec.name)
|
|
52
|
+
return specs
|
|
53
|
+
except Exception:
|
|
54
|
+
raise ConnectorError(
|
|
55
|
+
f"Connector {connector.id!r}: invalid required_secrets declaration"
|
|
56
|
+
) from None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def initialize_vault(vault: Vault) -> None:
|
|
60
|
+
"""Create private parent directories and initialize a new vault.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
vault: Vault whose existing files must never be overwritten.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
vault.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
67
|
+
vault.key_file.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
68
|
+
except OSError:
|
|
69
|
+
raise SecretError("Cannot create vault or key directory") from None
|
|
70
|
+
vault.initialize()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_values(vault: Vault) -> dict[str, str]:
|
|
74
|
+
if vault.path.exists() or vault.key_file.exists():
|
|
75
|
+
return vault.values()
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _require_terminal() -> None:
|
|
80
|
+
if not sys.stdin.isatty():
|
|
81
|
+
raise SecretError(
|
|
82
|
+
"Interactive secret configuration requires a terminal; "
|
|
83
|
+
"run 'yamcps secrets set --connector <name>' in a terminal first"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _prompt(connector: str, spec: SecretSpec, *, existing: bool = False) -> str:
|
|
88
|
+
label = f"{connector}/{spec.name}"
|
|
89
|
+
if spec.description:
|
|
90
|
+
print(f"{label}: {spec.description}", file=sys.stderr)
|
|
91
|
+
suffix = " (empty: keep existing)" if existing else ""
|
|
92
|
+
with warnings.catch_warnings():
|
|
93
|
+
warnings.simplefilter("error", getpass.GetPassWarning)
|
|
94
|
+
while True:
|
|
95
|
+
try:
|
|
96
|
+
value = getpass.getpass(f"{label}{suffix}: ", stream=sys.stderr)
|
|
97
|
+
except (getpass.GetPassWarning, EOFError, KeyboardInterrupt):
|
|
98
|
+
raise SecretError(
|
|
99
|
+
"Secret configuration cancelled or hidden input unavailable"
|
|
100
|
+
) from None
|
|
101
|
+
if value or existing:
|
|
102
|
+
return value
|
|
103
|
+
print("A nonempty value is required.", file=sys.stderr)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _persist(vault: Vault, updates: dict[str, str]) -> dict[str, str]:
|
|
107
|
+
if updates:
|
|
108
|
+
if not vault.path.exists() and not vault.key_file.exists():
|
|
109
|
+
initialize_vault(vault)
|
|
110
|
+
vault.update(updates)
|
|
111
|
+
return _read_values(vault)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def prepare_secrets(
|
|
115
|
+
connectors: Sequence[Connector], vault: Vault, *, interactive: bool
|
|
116
|
+
) -> dict[str, str]:
|
|
117
|
+
"""Collect only missing declared credentials before connector configuration.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
connectors: Selected connector instances; no other plugins are loaded.
|
|
121
|
+
vault: Credential store, initialized only when values need to be saved.
|
|
122
|
+
interactive: Whether terminal prompting is allowed.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Decrypted credentials to inject into connector configuration.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
SecretError: Credentials are missing without a terminal, or vault access fails.
|
|
129
|
+
"""
|
|
130
|
+
declared = [(connector.id, requirements(connector)) for connector in connectors]
|
|
131
|
+
values = _read_values(vault)
|
|
132
|
+
missing = [
|
|
133
|
+
(identifier, spec)
|
|
134
|
+
for identifier, specs in declared
|
|
135
|
+
for spec in specs
|
|
136
|
+
if ConnectorSecrets(identifier, values).get(spec.name) is None
|
|
137
|
+
]
|
|
138
|
+
if not missing:
|
|
139
|
+
return values
|
|
140
|
+
if not interactive or not sys.stdin.isatty():
|
|
141
|
+
commands = "; ".join(
|
|
142
|
+
f"yamcps secrets set --connector {identifier}"
|
|
143
|
+
for identifier in dict.fromkeys(item[0] for item in missing)
|
|
144
|
+
)
|
|
145
|
+
raise SecretError(f"Missing required secrets; configure them in a terminal: {commands}")
|
|
146
|
+
_require_terminal()
|
|
147
|
+
updates = {
|
|
148
|
+
_variable(identifier, spec.name): _prompt(identifier, spec) for identifier, spec in missing
|
|
149
|
+
}
|
|
150
|
+
return _persist(vault, updates)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _variable(connector: str, name: str) -> str:
|
|
154
|
+
return f"YAMCPS_{_identifier(connector)}_{_identifier(name)}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def configure_secrets(connector: str, specs: Sequence[SecretSpec], vault: Vault) -> None:
|
|
158
|
+
"""Run the explicit configuration wizard for one connector.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
connector: Connector identifier.
|
|
162
|
+
specs: Required fields, or one explicitly selected secret.
|
|
163
|
+
vault: Credential store to update after all prompts succeed.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
SecretError: No fields are declared or hidden terminal input is unavailable.
|
|
167
|
+
"""
|
|
168
|
+
if not specs:
|
|
169
|
+
raise SecretError(
|
|
170
|
+
f"Connector {connector!r} declares no required secrets; "
|
|
171
|
+
"use --name to configure a specific secret"
|
|
172
|
+
)
|
|
173
|
+
_require_terminal()
|
|
174
|
+
values = _read_values(vault)
|
|
175
|
+
updates = {}
|
|
176
|
+
for spec in specs:
|
|
177
|
+
value = _prompt(
|
|
178
|
+
connector, spec, existing=ConnectorSecrets(connector, values).get(spec.name) is not None
|
|
179
|
+
)
|
|
180
|
+
if value:
|
|
181
|
+
updates[_variable(connector, spec.name)] = value
|
|
182
|
+
_persist(vault, updates)
|
yamcps/py.typed
ADDED
|
File without changes
|
yamcps/registry.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Reject duplicate registrations instead of the SDK's default replacement."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from mcp.server import MCPServer
|
|
7
|
+
from mcp.server.mcpserver.prompts import Prompt
|
|
8
|
+
from mcp.server.mcpserver.resources import Resource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StrictServer(MCPServer):
|
|
12
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
13
|
+
self._yamcps_names: dict[str, set[str]] = {
|
|
14
|
+
"tool": set(),
|
|
15
|
+
"prompt": set(),
|
|
16
|
+
"resource": set(),
|
|
17
|
+
}
|
|
18
|
+
super().__init__(**kwargs)
|
|
19
|
+
|
|
20
|
+
def _claim(self, kind: str, name: str) -> None:
|
|
21
|
+
if name in self._yamcps_names[kind]:
|
|
22
|
+
raise ValueError(f"Duplicate {kind}: {name}")
|
|
23
|
+
self._yamcps_names[kind].add(name)
|
|
24
|
+
|
|
25
|
+
def add_tool(
|
|
26
|
+
self, fn: Callable[..., Any], name: str | None = None, *args: Any, **kwargs: Any
|
|
27
|
+
) -> None:
|
|
28
|
+
self._claim("tool", name or fn.__name__)
|
|
29
|
+
super().add_tool(fn, name, *args, **kwargs)
|
|
30
|
+
|
|
31
|
+
def add_prompt(self, prompt: Prompt) -> None:
|
|
32
|
+
self._claim("prompt", prompt.name)
|
|
33
|
+
super().add_prompt(prompt)
|
|
34
|
+
|
|
35
|
+
def add_resource(self, resource: Resource) -> None:
|
|
36
|
+
self._claim("resource", str(resource.uri))
|
|
37
|
+
super().add_resource(resource)
|
yamcps/secrets.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Connector-scoped credentials for outbound API authentication."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from pydantic import SecretStr
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SecretError(ValueError):
|
|
13
|
+
"""A required secret is missing or invalid; messages never include values."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _identifier(value: str) -> str:
|
|
17
|
+
if not re.fullmatch(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*", value):
|
|
18
|
+
raise SecretError("Expected a lowercase kebab-case identifier")
|
|
19
|
+
return value.upper().replace("-", "_")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class TokenAuth:
|
|
24
|
+
"""An API token with a configurable Authorization scheme."""
|
|
25
|
+
|
|
26
|
+
token: SecretStr
|
|
27
|
+
scheme: str = "Bearer"
|
|
28
|
+
|
|
29
|
+
def authorization_header(self) -> SecretStr:
|
|
30
|
+
"""Build a masked Authorization header.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A secret header; explicitly call get_secret_value() to send it.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
SecretError: The token or authentication scheme is invalid.
|
|
37
|
+
"""
|
|
38
|
+
token = self.token.get_secret_value()
|
|
39
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]*", self.scheme):
|
|
40
|
+
raise SecretError("Invalid token authentication scheme")
|
|
41
|
+
if not token or any(not 33 <= ord(character) <= 126 for character in token):
|
|
42
|
+
raise SecretError("Invalid API token")
|
|
43
|
+
return SecretStr(f"{self.scheme} {token}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class BasicAuth:
|
|
48
|
+
"""Username and password for HTTP Basic authentication."""
|
|
49
|
+
|
|
50
|
+
username: SecretStr
|
|
51
|
+
password: SecretStr
|
|
52
|
+
|
|
53
|
+
def authorization_header(self) -> SecretStr:
|
|
54
|
+
"""Build a masked, UTF-8 Basic Authorization header.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
A secret header; explicitly call get_secret_value() to send it.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
SecretError: The username is empty or contains a colon.
|
|
61
|
+
"""
|
|
62
|
+
username = self.username.get_secret_value()
|
|
63
|
+
if not username or ":" in username:
|
|
64
|
+
raise SecretError("Invalid Basic authentication username")
|
|
65
|
+
credentials = f"{username}:{self.password.get_secret_value()}".encode("utf-8")
|
|
66
|
+
encoded = base64.b64encode(credentials).decode("ascii")
|
|
67
|
+
return SecretStr(f"Basic {encoded}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ConnectorSecrets:
|
|
71
|
+
"""Read only the credentials named for one connector."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, connector_id: str, values: Mapping[str, str] | None = None) -> None:
|
|
74
|
+
"""Create a connector-scoped view over a credential source.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
connector_id: Plugin identifier used to derive the environment prefix.
|
|
78
|
+
values: Credential source; defaults to the process environment.
|
|
79
|
+
"""
|
|
80
|
+
self._prefix = f"YAMCPS_{_identifier(connector_id)}_"
|
|
81
|
+
self._values = os.environ if values is None else values
|
|
82
|
+
|
|
83
|
+
def redact(self, message: str) -> str:
|
|
84
|
+
"""Mask this connector's raw secrets in a diagnostic.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
message: Diagnostic text supplied by a connector.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Text with known nonempty credential values replaced.
|
|
91
|
+
"""
|
|
92
|
+
values = sorted(
|
|
93
|
+
{
|
|
94
|
+
value
|
|
95
|
+
for name, value in self._values.items()
|
|
96
|
+
if name.startswith(self._prefix) and value
|
|
97
|
+
},
|
|
98
|
+
key=len,
|
|
99
|
+
reverse=True,
|
|
100
|
+
)
|
|
101
|
+
for value in values:
|
|
102
|
+
message = message.replace(value, "[REDACTED]")
|
|
103
|
+
return message
|
|
104
|
+
|
|
105
|
+
def get(self, name: str) -> SecretStr | None:
|
|
106
|
+
"""Read an optional secret without exposing its raw value.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
name: Lowercase kebab-case secret name, such as api-token.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
A masked secret, or None when the variable is unset or empty.
|
|
113
|
+
"""
|
|
114
|
+
value = self._values.get(self._prefix + _identifier(name))
|
|
115
|
+
return SecretStr(value) if value else None
|
|
116
|
+
|
|
117
|
+
def require(self, name: str) -> SecretStr:
|
|
118
|
+
"""Read a required secret.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
name: Lowercase kebab-case secret name.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
A masked secret.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
SecretError: The secret is missing or empty.
|
|
128
|
+
"""
|
|
129
|
+
value = self.get(name)
|
|
130
|
+
if value is None:
|
|
131
|
+
raise SecretError(f"Missing secret: {self._prefix}{_identifier(name)}")
|
|
132
|
+
return value
|
|
133
|
+
|
|
134
|
+
def token_auth(self, name: str = "token", *, scheme: str = "Bearer") -> TokenAuth:
|
|
135
|
+
"""Read and validate a token for an outbound API.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
name: Secret name holding the token.
|
|
139
|
+
scheme: API authentication scheme, usually Bearer or Token.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Masked token credentials.
|
|
143
|
+
"""
|
|
144
|
+
auth = TokenAuth(self.require(name), scheme)
|
|
145
|
+
auth.authorization_header()
|
|
146
|
+
return auth
|
|
147
|
+
|
|
148
|
+
def basic_auth(self) -> BasicAuth:
|
|
149
|
+
"""Read and validate username/password secrets.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Masked HTTP Basic credentials.
|
|
153
|
+
"""
|
|
154
|
+
auth = BasicAuth(self.require("username"), self.require("password"))
|
|
155
|
+
auth.authorization_header()
|
|
156
|
+
return auth
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class SecretSpec:
|
|
161
|
+
"""Describe one required credential without including any value."""
|
|
162
|
+
|
|
163
|
+
name: str
|
|
164
|
+
description: str = ""
|
|
165
|
+
|
|
166
|
+
def __post_init__(self) -> None:
|
|
167
|
+
_identifier(self.name)
|
yamcps/server.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Compose selected connectors before starting either transport."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
|
|
6
|
+
from mcp.server import MCPServer
|
|
7
|
+
|
|
8
|
+
from yamcps.connectors import ConnectorError, load
|
|
9
|
+
from yamcps.onboarding import prepare_secrets
|
|
10
|
+
from yamcps.registry import StrictServer
|
|
11
|
+
from yamcps.secrets import ConnectorSecrets, SecretError
|
|
12
|
+
from yamcps.skills import SkillCatalog
|
|
13
|
+
from yamcps.vault import Vault
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_server(
|
|
17
|
+
names: list[str] | None = None,
|
|
18
|
+
*,
|
|
19
|
+
secret_values: Mapping[str, str] | None = None,
|
|
20
|
+
vault: Vault | None = None,
|
|
21
|
+
interactive: bool = False,
|
|
22
|
+
) -> MCPServer:
|
|
23
|
+
"""Compose a server with optional connector-scoped secrets.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
names: Selected connector identifiers; None loads all installed plugins.
|
|
27
|
+
secret_values: Explicit values; None uses environment when no vault is given.
|
|
28
|
+
vault: Optional store to resolve declared secret requirements from.
|
|
29
|
+
interactive: Allow hidden prompts for missing vault credentials.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A configured MCP server, ready to run.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ConnectorError: Connector configuration or registration fails.
|
|
36
|
+
"""
|
|
37
|
+
if vault is not None and secret_values is not None:
|
|
38
|
+
raise SecretError("Supply either vault or secret_values, not both")
|
|
39
|
+
catalog = SkillCatalog()
|
|
40
|
+
connectors = load(names)
|
|
41
|
+
if vault is not None:
|
|
42
|
+
secret_values = prepare_secrets(connectors, vault, interactive=interactive)
|
|
43
|
+
for connector in connectors:
|
|
44
|
+
configure = getattr(connector, "configure", None)
|
|
45
|
+
if configure is not None:
|
|
46
|
+
try:
|
|
47
|
+
if not callable(configure) or inspect.iscoroutinefunction(configure):
|
|
48
|
+
raise TypeError("configure must be synchronous")
|
|
49
|
+
configure(ConnectorSecrets(connector.id, secret_values))
|
|
50
|
+
except SecretError as exc:
|
|
51
|
+
message = ConnectorSecrets(connector.id, secret_values).redact(str(exc))
|
|
52
|
+
raise ConnectorError(
|
|
53
|
+
f"Connector {connector.id!r} configuration: {message}"
|
|
54
|
+
) from None
|
|
55
|
+
except Exception:
|
|
56
|
+
raise ConnectorError(f"Connector {connector.id!r} configuration failed") from None
|
|
57
|
+
try:
|
|
58
|
+
roots = getattr(connector, "skill_roots", None)
|
|
59
|
+
if roots is not None:
|
|
60
|
+
catalog.add_roots(connector.id, roots())
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
message = ConnectorSecrets(connector.id, secret_values).redact(str(exc))
|
|
63
|
+
raise ConnectorError(f"Connector {connector.id!r} skills: {message}") from None
|
|
64
|
+
server = StrictServer(name="yamcps", version="0.1.0", extensions=[catalog])
|
|
65
|
+
for connector in connectors:
|
|
66
|
+
try:
|
|
67
|
+
connector.register(server)
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
message = ConnectorSecrets(connector.id, secret_values).redact(str(exc))
|
|
70
|
+
raise ConnectorError(f"Connector {connector.id!r} registration: {message}") from None
|
|
71
|
+
catalog.register_resources(server)
|
|
72
|
+
return server
|
yamcps/skills.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Immutable startup skill catalog and SDK extension compatibility adapter.
|
|
2
|
+
|
|
3
|
+
The published mcp 2.3 SDK has Extension but not mcp.server.skills.
|
|
4
|
+
Keep this adapter separate so the native implementation can replace it later.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import mimetypes
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Iterable, Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
from mcp.server import MCPServer
|
|
17
|
+
from mcp.server.context import ServerRequestContext
|
|
18
|
+
from mcp.server.extension import Extension, MethodBinding
|
|
19
|
+
from mcp.server.mcpserver.resources import BinaryResource, TextResource
|
|
20
|
+
from mcp.shared.exceptions import MCPError
|
|
21
|
+
from mcp.types import INVALID_PARAMS, RequestParams
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GetSkillParams(RequestParams):
|
|
25
|
+
uri: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SkillCatalog(Extension):
|
|
29
|
+
identifier = "io.modelcontextprotocol/skills"
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self.skills: dict[str, dict[str, Any]] = {}
|
|
33
|
+
self.files: dict[str, bytes] = {}
|
|
34
|
+
|
|
35
|
+
def add_roots(self, connector_id: str, roots: Iterable[Path]) -> None:
|
|
36
|
+
"""Each root is one skill directory, named like its frontmatter name."""
|
|
37
|
+
for root in roots:
|
|
38
|
+
root = Path(root)
|
|
39
|
+
if root.is_symlink():
|
|
40
|
+
raise ValueError(f"Skill {root}: symbolic links are not supported")
|
|
41
|
+
root = root.resolve(strict=True)
|
|
42
|
+
paths = sorted(root.rglob("*"))
|
|
43
|
+
if any(path.is_symlink() for path in paths):
|
|
44
|
+
raise ValueError(f"Skill {root}: symbolic links are not supported")
|
|
45
|
+
contents = {
|
|
46
|
+
path.relative_to(root).as_posix(): path.read_bytes()
|
|
47
|
+
for path in paths
|
|
48
|
+
if path.is_file() and path.resolve().is_relative_to(root)
|
|
49
|
+
}
|
|
50
|
+
raw = contents.get("SKILL.md", b"").decode("utf-8")
|
|
51
|
+
match = re.match(r"\A---\r?\n(.*?)\r?\n---(?:\r?\n|\Z)", raw, re.DOTALL)
|
|
52
|
+
if not match:
|
|
53
|
+
raise ValueError(f"Skill {root}: missing YAML frontmatter")
|
|
54
|
+
metadata = yaml.safe_load(match.group(1))
|
|
55
|
+
if not isinstance(metadata, dict):
|
|
56
|
+
raise ValueError(f"Skill {root}: frontmatter must be a mapping")
|
|
57
|
+
name = metadata.get("name")
|
|
58
|
+
description = metadata.get("description")
|
|
59
|
+
if (
|
|
60
|
+
not isinstance(name, str)
|
|
61
|
+
or len(name) > 64
|
|
62
|
+
or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name)
|
|
63
|
+
or not name.startswith(f"{connector_id}-")
|
|
64
|
+
or root.name != name
|
|
65
|
+
):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Skill {root}: name must match directory and start with {connector_id}-"
|
|
68
|
+
)
|
|
69
|
+
if not isinstance(description, str) or not description.strip():
|
|
70
|
+
raise ValueError(f"Skill {root}: description is required")
|
|
71
|
+
base = f"skill://{name}/"
|
|
72
|
+
uri = base + "SKILL.md"
|
|
73
|
+
if uri in self.skills:
|
|
74
|
+
raise ValueError(f"Duplicate skill: {name}")
|
|
75
|
+
resources = []
|
|
76
|
+
for relative, data in contents.items():
|
|
77
|
+
file_uri = base + quote(relative, safe="/")
|
|
78
|
+
self.files[file_uri] = data
|
|
79
|
+
resources.append(
|
|
80
|
+
{
|
|
81
|
+
"uri": file_uri,
|
|
82
|
+
"size": len(data),
|
|
83
|
+
"digest": "sha256:" + hashlib.sha256(data).hexdigest(),
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
self.skills[uri] = {"uri": uri, "frontmatter": metadata, "resources": resources}
|
|
87
|
+
|
|
88
|
+
async def list_skills(self, ctx: ServerRequestContext[Any, Any], params: RequestParams) -> Any:
|
|
89
|
+
return {"skills": list(self.skills.values())}
|
|
90
|
+
|
|
91
|
+
async def get_skill(self, ctx: ServerRequestContext[Any, Any], params: GetSkillParams) -> Any:
|
|
92
|
+
if params.uri not in self.skills:
|
|
93
|
+
raise MCPError(code=INVALID_PARAMS, message=f"Unknown skill: {params.uri}")
|
|
94
|
+
return {"skill": self.skills[params.uri]}
|
|
95
|
+
|
|
96
|
+
def methods(self) -> Sequence[MethodBinding]:
|
|
97
|
+
return [
|
|
98
|
+
MethodBinding("skills/list", RequestParams, self.list_skills),
|
|
99
|
+
MethodBinding("skills/get", GetSkillParams, self.get_skill),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
def register_resources(self, server: MCPServer) -> None:
|
|
103
|
+
for uri, data in self.files.items():
|
|
104
|
+
mime = mimetypes.guess_type(uri)[0] or "application/octet-stream"
|
|
105
|
+
try:
|
|
106
|
+
text = data.decode("utf-8")
|
|
107
|
+
except UnicodeDecodeError:
|
|
108
|
+
server.add_resource(BinaryResource(uri=uri, data=data, mime_type=mime))
|
|
109
|
+
else:
|
|
110
|
+
server.add_resource(TextResource(uri=uri, text=text, mime_type=mime))
|
yamcps/vault.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Authenticated local credential storage with a separately supplied random key."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cryptography.exceptions import InvalidTag
|
|
12
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
13
|
+
|
|
14
|
+
from yamcps.secrets import SecretError, _identifier
|
|
15
|
+
|
|
16
|
+
_HEADER = b"HMCPVAULT\x01"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _write_new(path: Path, data: bytes) -> None:
|
|
20
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
21
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
22
|
+
stream.write(data)
|
|
23
|
+
stream.flush()
|
|
24
|
+
os.fsync(stream.fileno())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Vault:
|
|
28
|
+
"""AES-256-GCM vault; no keyring, password derivation or plaintext export."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, path: Path, key_file: Path) -> None:
|
|
31
|
+
"""Locate a vault and its separate master key.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
path: Encrypted vault file.
|
|
35
|
+
key_file: File containing exactly 32 random key bytes.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
SecretError: The vault and key point to the same file.
|
|
39
|
+
"""
|
|
40
|
+
self.path = path.resolve()
|
|
41
|
+
self.key_file = key_file.resolve()
|
|
42
|
+
if self.path == self.key_file:
|
|
43
|
+
raise SecretError("Vault and key must be separate files")
|
|
44
|
+
|
|
45
|
+
def initialize(self) -> None:
|
|
46
|
+
"""Create a new key and empty vault without overwriting existing files.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
SecretError: Files already exist or cannot be created.
|
|
50
|
+
"""
|
|
51
|
+
if self.path.exists() or self.key_file.exists():
|
|
52
|
+
raise SecretError("Vault initialization requires two new file paths")
|
|
53
|
+
key = AESGCM.generate_key(bit_length=256)
|
|
54
|
+
created_key = False
|
|
55
|
+
try:
|
|
56
|
+
_write_new(self.key_file, key)
|
|
57
|
+
created_key = True
|
|
58
|
+
_write_new(self.path, self._encrypt({}, key))
|
|
59
|
+
except OSError:
|
|
60
|
+
if created_key:
|
|
61
|
+
self.key_file.unlink(missing_ok=True)
|
|
62
|
+
raise SecretError(
|
|
63
|
+
"Cannot initialize vault; check directories and permissions"
|
|
64
|
+
) from None
|
|
65
|
+
|
|
66
|
+
def _key(self) -> bytes:
|
|
67
|
+
try:
|
|
68
|
+
with self.key_file.open("rb") as stream:
|
|
69
|
+
if os.name == "posix" and stat.S_IMODE(os.fstat(stream.fileno()).st_mode) & 0o077:
|
|
70
|
+
raise SecretError("Master key permissions must restrict access to its owner")
|
|
71
|
+
key = stream.read(33)
|
|
72
|
+
except OSError:
|
|
73
|
+
raise SecretError("Cannot read master key") from None
|
|
74
|
+
if len(key) != 32:
|
|
75
|
+
raise SecretError("Master key must contain exactly 32 bytes")
|
|
76
|
+
return key
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _encrypt(values: dict[str, str], key: bytes) -> bytes:
|
|
80
|
+
nonce = os.urandom(12)
|
|
81
|
+
plaintext = json.dumps(values, ensure_ascii=False).encode("utf-8")
|
|
82
|
+
return _HEADER + nonce + AESGCM(key).encrypt(nonce, plaintext, _HEADER)
|
|
83
|
+
|
|
84
|
+
def values(self) -> dict[str, str]:
|
|
85
|
+
"""Decrypt credentials for server startup.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Raw credentials for internal injection; never log this mapping.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
SecretError: The key, file, authentication tag or format is invalid.
|
|
92
|
+
"""
|
|
93
|
+
key = self._key()
|
|
94
|
+
try:
|
|
95
|
+
data = self.path.read_bytes()
|
|
96
|
+
except OSError:
|
|
97
|
+
raise SecretError("Cannot read vault") from None
|
|
98
|
+
if not data.startswith(_HEADER) or len(data) < len(_HEADER) + 12 + 16:
|
|
99
|
+
raise SecretError("Invalid or unsupported vault format")
|
|
100
|
+
offset = len(_HEADER)
|
|
101
|
+
try:
|
|
102
|
+
plaintext = AESGCM(key).decrypt(
|
|
103
|
+
data[offset : offset + 12], data[offset + 12 :], _HEADER
|
|
104
|
+
)
|
|
105
|
+
values = json.loads(plaintext)
|
|
106
|
+
except (InvalidTag, ValueError, UnicodeDecodeError):
|
|
107
|
+
raise SecretError("Vault authentication failed or content is invalid") from None
|
|
108
|
+
if not isinstance(values, dict) or any(
|
|
109
|
+
not isinstance(name, str) or not isinstance(value, str)
|
|
110
|
+
for name, value in values.items()
|
|
111
|
+
):
|
|
112
|
+
raise SecretError("Invalid vault credential format")
|
|
113
|
+
return values
|
|
114
|
+
|
|
115
|
+
@contextmanager
|
|
116
|
+
def _edit(self) -> Iterator[dict[str, str]]:
|
|
117
|
+
lock = self.path.with_name(self.path.name + ".lock")
|
|
118
|
+
try:
|
|
119
|
+
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
120
|
+
except OSError:
|
|
121
|
+
raise SecretError("Vault is locked or not writable") from None
|
|
122
|
+
os.close(descriptor)
|
|
123
|
+
temporary: str | None = None
|
|
124
|
+
try:
|
|
125
|
+
values = self.values()
|
|
126
|
+
yield values
|
|
127
|
+
data = self._encrypt(values, self._key())
|
|
128
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".yamcps-vault-", dir=self.path.parent)
|
|
129
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
130
|
+
stream.write(data)
|
|
131
|
+
stream.flush()
|
|
132
|
+
os.fsync(stream.fileno())
|
|
133
|
+
os.replace(temporary, self.path)
|
|
134
|
+
except OSError:
|
|
135
|
+
raise SecretError("Cannot update vault") from None
|
|
136
|
+
finally:
|
|
137
|
+
if temporary is not None:
|
|
138
|
+
Path(temporary).unlink(missing_ok=True)
|
|
139
|
+
lock.unlink(missing_ok=True)
|
|
140
|
+
|
|
141
|
+
def set(self, connector: str, name: str, value: str) -> None:
|
|
142
|
+
"""Add or replace one credential.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
connector: Connector identifier.
|
|
146
|
+
name: Secret identifier.
|
|
147
|
+
value: Nonempty raw secret obtained through hidden input.
|
|
148
|
+
"""
|
|
149
|
+
variable = f"YAMCPS_{_identifier(connector)}_{_identifier(name)}"
|
|
150
|
+
if not value:
|
|
151
|
+
raise SecretError("Secret cannot be empty")
|
|
152
|
+
with self._edit() as values:
|
|
153
|
+
values[variable] = value
|
|
154
|
+
|
|
155
|
+
def update(self, values: dict[str, str]) -> None:
|
|
156
|
+
"""Atomically persist a batch collected by the interactive wizard.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
values: Nonempty credentials keyed by normalized YAMCPS variable names.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
SecretError: A credential name or value is invalid.
|
|
163
|
+
"""
|
|
164
|
+
if any(not name.startswith("YAMCPS_") or not value for name, value in values.items()):
|
|
165
|
+
raise SecretError("Invalid credential batch")
|
|
166
|
+
with self._edit() as stored:
|
|
167
|
+
stored.update(values)
|
|
168
|
+
|
|
169
|
+
def delete(self, connector: str, name: str) -> None:
|
|
170
|
+
"""Delete one credential, failing if it does not exist.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
connector: Connector identifier.
|
|
174
|
+
name: Secret identifier.
|
|
175
|
+
"""
|
|
176
|
+
variable = f"YAMCPS_{_identifier(connector)}_{_identifier(name)}"
|
|
177
|
+
with self._edit() as values:
|
|
178
|
+
if variable not in values:
|
|
179
|
+
raise SecretError("Secret does not exist")
|
|
180
|
+
del values[variable]
|
|
181
|
+
|
|
182
|
+
def names(self) -> list[str]:
|
|
183
|
+
"""Return sorted credential names without their values."""
|
|
184
|
+
return sorted(self.values())
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: yamcps
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Yet Another Model Context Protocole Server: a plugin-based MCP server with skills
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: cryptography<51,>=46
|
|
9
|
+
Requires-Dist: mcp<3,>=2.3
|
|
10
|
+
Requires-Dist: pydantic<3,>=2
|
|
11
|
+
Requires-Dist: pyyaml<7,>=6
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: build; extra == 'dev'
|
|
14
|
+
Requires-Dist: hatchling; extra == 'dev'
|
|
15
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest-asyncio>=1; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
19
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# yamcps — Yet Another Model Context Protocole Server
|
|
23
|
+
|
|
24
|
+
**yamcps** est un serveur MCP Python extensible qui expose les fonctionnalités de
|
|
25
|
+
connecteurs à des assistants compatibles avec le Model Context Protocol.
|
|
26
|
+
Python 3.12 ou supérieur est requis.
|
|
27
|
+
|
|
28
|
+
## Fonctionnalités
|
|
29
|
+
|
|
30
|
+
- Connecteurs indépendants distribuables sur PyPI sous le nom `yamcps-<nom>`.
|
|
31
|
+
- Chargement de tous les plugins installés par défaut, ou sélection avec `--connector`.
|
|
32
|
+
- Outils, ressources et prompts MCP fournis par les connecteurs.
|
|
33
|
+
- Plugins `yamcps-sonarr` et `yamcps-radarr` : bibliothèques de séries/films, calendriers,
|
|
34
|
+
téléchargements, recherche et ajout.
|
|
35
|
+
- Plugin `yamcps-prowlarr` : indexeurs, recherche de releases et diagnostics en lecture seule.
|
|
36
|
+
- Skills `SKILL.md` et fichiers associés consultables via MCP.
|
|
37
|
+
- Coffre local chiffré et configuration interactive des secrets requis par les connecteurs.
|
|
38
|
+
- Transports stdio et Streamable HTTP local.
|
|
39
|
+
- Plugins `yamcps-demo` et `yamcps-demo-secrets` pour tester les outils et la configuration des secrets.
|
|
40
|
+
|
|
41
|
+
## Démarrage depuis le dépôt
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
python -m venv .venv
|
|
45
|
+
source .venv/bin/activate
|
|
46
|
+
python -m pip install -e . -e plugins/yamcps-demo
|
|
47
|
+
yamcps connectors list
|
|
48
|
+
yamcps serve --connector demo
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Pour charger tous les plugins installés : `yamcps serve`. Pour en sélectionner plusieurs :
|
|
52
|
+
`yamcps serve --connector demo --connector demo-secrets`.
|
|
53
|
+
|
|
54
|
+
Pour HTTP : `yamcps serve --connector demo --transport http --port 8000`.
|
|
55
|
+
Le serveur écoute sur `http://127.0.0.1:8000/mcp`.
|
|
56
|
+
|
|
57
|
+
## Documentation
|
|
58
|
+
|
|
59
|
+
- [Manuel utilisateur](docs/user-manual.md) : installation, clients et utilisation.
|
|
60
|
+
- [Architecture](docs/design.md) : contrats des connecteurs et gestion des skills.
|
|
61
|
+
- [Contribution](contributing.md) : conventions, tests et contrôles qualité.
|
|
62
|
+
- [Consignes pour les agents](AGENTS.md) : règles de travail dans le dépôt.
|
|
63
|
+
|
|
64
|
+
Le nom PyPI `yamcps` est réservé pour ce projet. Seule la distribution de base
|
|
65
|
+
est publiée par le [workflow de release](.github/workflows/workflow.yaml).
|
|
66
|
+
Les plugins restent installables depuis le dépôt. Voir le guide de contribution
|
|
67
|
+
pour configurer Trusted Publishing et publier une version.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
yamcps/__init__.py,sha256=45XIfAPIIlxWQDiyqtFK7t7Lc6w8Qb-E1fimCIFKQ6U,471
|
|
2
|
+
yamcps/__main__.py,sha256=s_hB8Oj6ncX8xKsjFR7xpOpGD6FTZZ46pZZGEsGFhEg,36
|
|
3
|
+
yamcps/cli.py,sha256=ipT0t_bC6-FK2E3RH3MXMmWHnqfRJk8GItB9zB7CnA8,3300
|
|
4
|
+
yamcps/connectors.py,sha256=6OSaWjnAz_E-a5umbzPWAvC9_4MgBKzFfd2hrVZkwc8,3218
|
|
5
|
+
yamcps/onboarding.py,sha256=MOMG5ej9Mzjj88AIp-v-6KZp8sptGPOxd3x-IFk4TMI,6249
|
|
6
|
+
yamcps/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
yamcps/registry.py,sha256=EWZSdvK65EMGzSzsEx0AcJFeIhkMiJYMXJsZTTZbIvA,1237
|
|
8
|
+
yamcps/secrets.py,sha256=x-pTiNuHHI0p3veQSdm8uI55Unig2W-ybgauctkAbzI,5234
|
|
9
|
+
yamcps/server.py,sha256=VlfHABG2g4rCtznEo7uNFusI8euoVTwyvKuMi6NkAF8,3039
|
|
10
|
+
yamcps/skills.py,sha256=n2UjYRystE82Mrm5mzEe07LmwLVgUi-1NRsbV-nf5A0,4701
|
|
11
|
+
yamcps/vault.py,sha256=_avZvYqnWufsPVtYL9NZq_E5bVI-fDhkvGqpdLeqbV4,6906
|
|
12
|
+
yamcps-0.1.0.dist-info/METADATA,sha256=h0rp9Rt5I4O-jG8ZwDvFl8t3YTcc4b8TsSZeDzvhjEs,2838
|
|
13
|
+
yamcps-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
14
|
+
yamcps-0.1.0.dist-info/entry_points.txt,sha256=uTGiNtODerocojQyp80Zzl5RojGrwGUxqUy8p5-twKA,43
|
|
15
|
+
yamcps-0.1.0.dist-info/licenses/LICENSE,sha256=lYIxZwOPglSE9QmzRMjPVmsKnxiU3Dra0YYOGRfBa64,1063
|
|
16
|
+
yamcps-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JoReci
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|