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 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
@@ -0,0 +1,3 @@
1
+ from yamcps.cli import main
2
+
3
+ main()
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ yamcps = yamcps.cli:main
@@ -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.