asm-protocol 0.5.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.
- asm_cli.py +626 -0
- asm_protocol-0.5.0.dist-info/METADATA +395 -0
- asm_protocol-0.5.0.dist-info/RECORD +21 -0
- asm_protocol-0.5.0.dist-info/WHEEL +5 -0
- asm_protocol-0.5.0.dist-info/entry_points.txt +4 -0
- asm_protocol-0.5.0.dist-info/licenses/LICENSE +21 -0
- asm_protocol-0.5.0.dist-info/top_level.txt +7 -0
- asm_select_api.py +144 -0
- asm_selector_mcp.py +94 -0
- library_select.py +149 -0
- mcp_server_json_asm.py +177 -0
- openrouter_adapter.py +421 -0
- scorer/__init__.py +45 -0
- scorer/data/elo_snapshot.json +5027 -0
- scorer/scorer.py +883 -0
- scorer/test_langchain_adapter.py +54 -0
- scorer/test_library_select.py +104 -0
- scorer/test_manifests_schema.py +55 -0
- scorer/test_mcp_server_json_asm.py +84 -0
- scorer/test_openrouter_adapter.py +238 -0
- scorer/test_scorer.py +447 -0
asm_selector_mcp.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ASM Selector — an MCP server that lets any MCP client (Claude Desktop, Cursor,
|
|
3
|
+
agent hosts) query the ASM tool-value library and pick a tool for a task.
|
|
4
|
+
|
|
5
|
+
This is ASM's productization wedge: the agent doesn't adopt a schema, it just
|
|
6
|
+
calls `select_tool` and gets back which tool to use, why, and the operational
|
|
7
|
+
policy (risk / approval / side-effects) to gate the action.
|
|
8
|
+
|
|
9
|
+
Run (stdio): python asm_selector_mcp.py (or `asm-selector` once installed)
|
|
10
|
+
Requires: pip install mcp (or: pip install "asm-protocol[mcp]")
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from mcp.server.fastmcp import FastMCP
|
|
15
|
+
|
|
16
|
+
from library_select import load_library, monthly_cost, select
|
|
17
|
+
|
|
18
|
+
mcp = FastMCP("asm-selector")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@mcp.tool()
|
|
22
|
+
def select_tool(
|
|
23
|
+
task: str,
|
|
24
|
+
taxonomy: str | None = None,
|
|
25
|
+
agent_reach: str = "cloud",
|
|
26
|
+
user_platform: str = "any",
|
|
27
|
+
required_functions: list[str] | None = None,
|
|
28
|
+
require_approval_for: list[str] | None = None,
|
|
29
|
+
require_agent_completable_setup: bool = False,
|
|
30
|
+
) -> dict:
|
|
31
|
+
"""Pick the best tool from the ASM library for a task.
|
|
32
|
+
|
|
33
|
+
Filters out tools the agent can't drive (invocation/reach/platform), tools
|
|
34
|
+
that forbid automation, and tools missing the required functions; then ranks
|
|
35
|
+
the rest cheapest-first. Returns the selected tool plus its operational
|
|
36
|
+
policy so the caller can gate high-stakes actions before invoking.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
task: natural-language description of what the agent must do.
|
|
40
|
+
taxonomy: optional ASM taxonomy to scope candidates (e.g. tool.booking.travel).
|
|
41
|
+
agent_reach: 'cloud' (remote/headless) or 'local_device' (runs on user's machine).
|
|
42
|
+
user_platform: e.g. windows, macos, ios, android, web, any.
|
|
43
|
+
required_functions: capability names the tool must have (e.g. ["flight_search"]).
|
|
44
|
+
require_approval_for: side-effects that should force approval (e.g. ["financial_charge","sends_message"]).
|
|
45
|
+
require_agent_completable_setup: if true, drop tools that need a human-in-the-loop
|
|
46
|
+
step (paid signup, OAuth consent, manual approval) the agent cannot complete unattended.
|
|
47
|
+
"""
|
|
48
|
+
return select(
|
|
49
|
+
task,
|
|
50
|
+
taxonomy=taxonomy,
|
|
51
|
+
agent_reach=agent_reach,
|
|
52
|
+
user_platform=user_platform,
|
|
53
|
+
required_functions=required_functions or [],
|
|
54
|
+
require_approval_for=require_approval_for or [],
|
|
55
|
+
require_agent_completable_setup=require_agent_completable_setup,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
def list_library_tools(taxonomy: str | None = None) -> list[dict]:
|
|
61
|
+
"""List tools in the ASM library (optionally filtered by taxonomy), with
|
|
62
|
+
interface/reach and monthly cost so a client can browse what's selectable."""
|
|
63
|
+
out = []
|
|
64
|
+
for m in load_library():
|
|
65
|
+
if taxonomy and m.get("taxonomy") != taxonomy:
|
|
66
|
+
continue
|
|
67
|
+
inv = m.get("invocation") or {}
|
|
68
|
+
out.append({
|
|
69
|
+
"service_id": m.get("service_id"),
|
|
70
|
+
"display_name": m.get("display_name"),
|
|
71
|
+
"taxonomy": m.get("taxonomy"),
|
|
72
|
+
"interface": inv.get("interface"),
|
|
73
|
+
"reach": inv.get("reach"),
|
|
74
|
+
"agent_operable": inv.get("agent_operable"),
|
|
75
|
+
"monthly_cost_usd": round(monthly_cost(m), 2),
|
|
76
|
+
})
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
def get_tool_manifest(service_id: str) -> dict:
|
|
82
|
+
"""Return the full ASM manifest for a given service_id."""
|
|
83
|
+
for m in load_library():
|
|
84
|
+
if m.get("service_id") == service_id:
|
|
85
|
+
return m
|
|
86
|
+
return {"error": f"no tool with service_id={service_id}"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main() -> None:
|
|
90
|
+
mcp.run()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
main()
|
library_select.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Importable ASM tool-selector over the library/ manifests.
|
|
3
|
+
|
|
4
|
+
One selector, shared by the demo, the CLI, and the MCP server. Implements the
|
|
5
|
+
confirmed priority order:
|
|
6
|
+
|
|
7
|
+
gate 1: agent can invoke it (agent_operable; reach matches the agent env)
|
|
8
|
+
gate 2: automated use is allowed (usage_terms.automation_allowed != 'no')
|
|
9
|
+
gate 3: meets task requirements (required functions present; platform fits)
|
|
10
|
+
rank : among survivors, cheapest first, then feature-richest
|
|
11
|
+
|
|
12
|
+
It also surfaces the operational policy of the pick (risk_class / approval /
|
|
13
|
+
side_effects) so the caller can gate high-stakes actions (send message, spend
|
|
14
|
+
money, mutate a repo) before invoking.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import glob
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# Default to the library/ next to this module (works from a checkout); override
|
|
24
|
+
# with ASM_LIBRARY_DIR so the MCP server / API can point at any library location.
|
|
25
|
+
LIBRARY_DIR = Path(os.environ.get("ASM_LIBRARY_DIR")
|
|
26
|
+
or (Path(__file__).resolve().parent / "library"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_library(library_dir: str | Path | None = None) -> list[dict]:
|
|
30
|
+
d = Path(library_dir) if library_dir else LIBRARY_DIR
|
|
31
|
+
return [json.loads(Path(p).read_text(encoding="utf-8"))
|
|
32
|
+
for p in glob.glob(str(d / "**" / "*.asm.json"), recursive=True)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def monthly_cost(m: dict) -> float:
|
|
36
|
+
dims = (m.get("pricing") or {}).get("billing_dimensions") or []
|
|
37
|
+
free = "free_tier" in (m.get("payment") or {}).get("methods", [])
|
|
38
|
+
base = 0.0 if free else 1e9
|
|
39
|
+
saw_zero = False
|
|
40
|
+
for d in dims:
|
|
41
|
+
c, u = d.get("cost_per_unit", 0), d.get("unit", "")
|
|
42
|
+
if c == 0:
|
|
43
|
+
saw_zero = True
|
|
44
|
+
continue
|
|
45
|
+
base = min(base, c if "month" in u else c / 12 if "year" in u else c / 24)
|
|
46
|
+
if base == 1e9 and saw_zero:
|
|
47
|
+
return 0.0
|
|
48
|
+
return 0.0 if free else base
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def eligibility(m: dict, agent_reach: str, user_platform: str, required_functions,
|
|
52
|
+
require_agent_completable_setup: bool = False) -> str | None:
|
|
53
|
+
"""Return None if the tool is eligible, else a human-readable rejection reason."""
|
|
54
|
+
inv = m.get("invocation", {})
|
|
55
|
+
if not inv.get("agent_operable"):
|
|
56
|
+
return "not agent-operable"
|
|
57
|
+
if inv.get("reach") == "local_device" and agent_reach != "local_device":
|
|
58
|
+
return f"reach=local_device but agent is {agent_reach} (can't drive remotely)"
|
|
59
|
+
if require_agent_completable_setup and inv.get("agent_completable_setup") is False:
|
|
60
|
+
req = ", ".join(inv.get("setup_requires") or []) or "human-in-the-loop"
|
|
61
|
+
return f"setup not agent-completable (requires {req})"
|
|
62
|
+
if (m.get("usage_terms") or {}).get("automation_allowed") == "no":
|
|
63
|
+
return "ToS forbids automated use"
|
|
64
|
+
plats = inv.get("platforms", [])
|
|
65
|
+
if not ({"any", "web"} & set(plats)) and user_platform not in plats:
|
|
66
|
+
return f"platform {user_platform} unsupported ({plats})"
|
|
67
|
+
funcs = set((m.get("capabilities") or {}).get("functions", []))
|
|
68
|
+
missing = [f for f in required_functions if f not in funcs]
|
|
69
|
+
if missing:
|
|
70
|
+
return f"missing required functions: {missing}"
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def policy_of(m: dict, require_approval_for=()) -> dict:
|
|
75
|
+
ops = m.get("operational_constraints") or {}
|
|
76
|
+
approval = ops.get("approval") or {}
|
|
77
|
+
side = ops.get("side_effects", []) or []
|
|
78
|
+
req = approval.get("required")
|
|
79
|
+
approval_required = (req == "always") or bool(set(side) & set(require_approval_for or ()))
|
|
80
|
+
return {
|
|
81
|
+
"risk_class": ops.get("risk_class", "unknown"),
|
|
82
|
+
"approval_policy": req or "unknown",
|
|
83
|
+
"approval_required": approval_required,
|
|
84
|
+
"side_effects": side,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def rank(task: str, *, taxonomy: str | None = None, agent_reach: str = "cloud",
|
|
89
|
+
user_platform: str = "any", required_functions=(),
|
|
90
|
+
require_agent_completable_setup: bool = False, library=None):
|
|
91
|
+
"""Return (kept_manifests_sorted, rejected[{service, service_id, reason}])."""
|
|
92
|
+
entries = library if library is not None else load_library()
|
|
93
|
+
pool = [m for m in entries if taxonomy is None or m.get("taxonomy") == taxonomy]
|
|
94
|
+
kept, rejected = [], []
|
|
95
|
+
for m in pool:
|
|
96
|
+
why = eligibility(m, agent_reach, user_platform, list(required_functions),
|
|
97
|
+
require_agent_completable_setup=require_agent_completable_setup)
|
|
98
|
+
if why:
|
|
99
|
+
rejected.append({"service": m.get("display_name"),
|
|
100
|
+
"service_id": m.get("service_id"), "reason": why})
|
|
101
|
+
else:
|
|
102
|
+
kept.append(m)
|
|
103
|
+
kept.sort(key=lambda m: (monthly_cost(m),
|
|
104
|
+
-len((m.get("capabilities") or {}).get("functions", []))))
|
|
105
|
+
return kept, rejected
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def select(task: str, *, taxonomy: str | None = None, agent_reach: str = "cloud",
|
|
109
|
+
user_platform: str = "any", required_functions=(),
|
|
110
|
+
require_approval_for=(), require_agent_completable_setup: bool = False,
|
|
111
|
+
library=None) -> dict:
|
|
112
|
+
"""Structured selection decision (used by the CLI, MCP server, hosted API)."""
|
|
113
|
+
kept, rejected = rank(task, taxonomy=taxonomy, agent_reach=agent_reach,
|
|
114
|
+
user_platform=user_platform,
|
|
115
|
+
required_functions=required_functions,
|
|
116
|
+
require_agent_completable_setup=require_agent_completable_setup,
|
|
117
|
+
library=library)
|
|
118
|
+
out = {
|
|
119
|
+
"task": task, "taxonomy": taxonomy,
|
|
120
|
+
"selected": None, "reason": "no eligible tool",
|
|
121
|
+
"risk_class": None, "approval_required": None, "side_effects": [],
|
|
122
|
+
"alternatives": [], "rejected": rejected,
|
|
123
|
+
}
|
|
124
|
+
if kept:
|
|
125
|
+
top = kept[0]
|
|
126
|
+
pol = policy_of(top, require_approval_for)
|
|
127
|
+
inv = top.get("invocation") or {}
|
|
128
|
+
out["selected"] = {
|
|
129
|
+
"service_id": top.get("service_id"),
|
|
130
|
+
"display_name": top.get("display_name"),
|
|
131
|
+
"monthly_cost_usd": round(monthly_cost(top), 2),
|
|
132
|
+
"interface": inv.get("interface"),
|
|
133
|
+
"reach": inv.get("reach"),
|
|
134
|
+
"agent_completable_setup": inv.get("agent_completable_setup"),
|
|
135
|
+
"setup_requires": inv.get("setup_requires", []),
|
|
136
|
+
}
|
|
137
|
+
out["risk_class"] = pol["risk_class"]
|
|
138
|
+
out["approval_required"] = pol["approval_required"]
|
|
139
|
+
out["side_effects"] = pol["side_effects"]
|
|
140
|
+
out["reason"] = (
|
|
141
|
+
f"Eligible {pol['risk_class']}-risk tool with the required functions; "
|
|
142
|
+
+ ("approval required before invocation." if pol["approval_required"]
|
|
143
|
+
else "no approval gate triggered.")
|
|
144
|
+
)
|
|
145
|
+
out["alternatives"] = [
|
|
146
|
+
{"service_id": m.get("service_id"), "display_name": m.get("display_name"),
|
|
147
|
+
"monthly_cost_usd": round(monthly_cost(m), 2)} for m in kept[1:]
|
|
148
|
+
]
|
|
149
|
+
return out
|
mcp_server_json_asm.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract and validate ASM metadata embedded in MCP Registry server.json files."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from jsonschema import Draft202012Validator
|
|
15
|
+
except Exception: # pragma: no cover - exercised only without optional dependency
|
|
16
|
+
Draft202012Validator = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ROOT = Path(__file__).resolve().parent
|
|
20
|
+
SCHEMA_PATH = ROOT / "schema" / "asm-v0.3.schema.json"
|
|
21
|
+
PUBLISHER_META_KEY = "io.modelcontextprotocol.registry/publisher-provided"
|
|
22
|
+
RECOMMENDED_VALUE_FIELDS = ("pricing", "sla", "quality", "provenance", "payment")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class InspectionResult:
|
|
27
|
+
path: str
|
|
28
|
+
has_asm: bool
|
|
29
|
+
valid_asm: bool
|
|
30
|
+
can_convert: bool
|
|
31
|
+
asm_service_id: str | None = None
|
|
32
|
+
asm_taxonomy: str | None = None
|
|
33
|
+
missing_recommended_fields: list[str] = field(default_factory=list)
|
|
34
|
+
warnings: list[str] = field(default_factory=list)
|
|
35
|
+
errors: list[str] = field(default_factory=list)
|
|
36
|
+
asm_manifest: dict[str, Any] | None = None
|
|
37
|
+
|
|
38
|
+
def as_dict(self) -> dict[str, Any]:
|
|
39
|
+
data = {
|
|
40
|
+
"path": self.path,
|
|
41
|
+
"has_asm": self.has_asm,
|
|
42
|
+
"valid_asm": self.valid_asm,
|
|
43
|
+
"can_convert": self.can_convert,
|
|
44
|
+
"asm_service_id": self.asm_service_id,
|
|
45
|
+
"asm_taxonomy": self.asm_taxonomy,
|
|
46
|
+
"missing_recommended_fields": self.missing_recommended_fields,
|
|
47
|
+
"warnings": self.warnings,
|
|
48
|
+
"errors": self.errors,
|
|
49
|
+
}
|
|
50
|
+
if self.asm_manifest is not None:
|
|
51
|
+
data["asm_manifest"] = self.asm_manifest
|
|
52
|
+
return data
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_json(path: Path) -> dict[str, Any]:
|
|
56
|
+
with path.open("r", encoding="utf-8-sig") as fp:
|
|
57
|
+
data = json.load(fp)
|
|
58
|
+
if not isinstance(data, dict):
|
|
59
|
+
raise ValueError("server.json root must be an object")
|
|
60
|
+
return data
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def extract_asm(server_json: dict[str, Any]) -> tuple[dict[str, Any] | None, list[str], list[str]]:
|
|
64
|
+
warnings: list[str] = []
|
|
65
|
+
errors: list[str] = []
|
|
66
|
+
meta = server_json.get("_meta")
|
|
67
|
+
if not isinstance(meta, dict):
|
|
68
|
+
return None, ["No _meta object found; this server.json has no inline ASM metadata."], errors
|
|
69
|
+
|
|
70
|
+
publisher = meta.get(PUBLISHER_META_KEY)
|
|
71
|
+
if not isinstance(publisher, dict):
|
|
72
|
+
return None, [f"No _meta.{PUBLISHER_META_KEY} object found; no publisher-provided ASM metadata."], errors
|
|
73
|
+
|
|
74
|
+
asm = publisher.get("asm")
|
|
75
|
+
asm_url = publisher.get("asm_url")
|
|
76
|
+
if asm is None and asm_url:
|
|
77
|
+
return None, [f"ASM is referenced by asm_url={asm_url!r}; inline conversion is unavailable."], errors
|
|
78
|
+
if asm is None:
|
|
79
|
+
return None, ["No publisher-provided asm object found."], errors
|
|
80
|
+
if not isinstance(asm, dict):
|
|
81
|
+
return None, warnings, ["publisher-provided asm must be a JSON object."]
|
|
82
|
+
return asm, warnings, errors
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_manifest(manifest: dict[str, Any]) -> list[str]:
|
|
86
|
+
if Draft202012Validator is None:
|
|
87
|
+
required = {"asm_version", "service_id", "taxonomy"}
|
|
88
|
+
return [f"Missing required field: {field}" for field in sorted(required - set(manifest))]
|
|
89
|
+
|
|
90
|
+
schema = load_json(SCHEMA_PATH)
|
|
91
|
+
validator = Draft202012Validator(schema)
|
|
92
|
+
errors = []
|
|
93
|
+
for err in sorted(validator.iter_errors(manifest), key=lambda e: list(e.path)):
|
|
94
|
+
path = ".".join(str(p) for p in err.path) or "<root>"
|
|
95
|
+
errors.append(f"{path}: {err.message}")
|
|
96
|
+
return errors
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def inspect_server_json(path: str | Path) -> InspectionResult:
|
|
100
|
+
path = Path(path)
|
|
101
|
+
try:
|
|
102
|
+
server_json = load_json(path)
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
return InspectionResult(path=str(path), has_asm=False, valid_asm=False, can_convert=False, errors=[str(exc)])
|
|
105
|
+
|
|
106
|
+
asm, warnings, extraction_errors = extract_asm(server_json)
|
|
107
|
+
if asm is None:
|
|
108
|
+
return InspectionResult(
|
|
109
|
+
path=str(path),
|
|
110
|
+
has_asm=False,
|
|
111
|
+
valid_asm=False,
|
|
112
|
+
can_convert=False,
|
|
113
|
+
warnings=warnings,
|
|
114
|
+
errors=extraction_errors,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
validation_errors = validate_manifest(asm)
|
|
118
|
+
missing = [field for field in RECOMMENDED_VALUE_FIELDS if field not in asm]
|
|
119
|
+
return InspectionResult(
|
|
120
|
+
path=str(path),
|
|
121
|
+
has_asm=True,
|
|
122
|
+
valid_asm=not validation_errors,
|
|
123
|
+
can_convert=not validation_errors,
|
|
124
|
+
asm_service_id=asm.get("service_id"),
|
|
125
|
+
asm_taxonomy=asm.get("taxonomy"),
|
|
126
|
+
missing_recommended_fields=missing,
|
|
127
|
+
warnings=warnings,
|
|
128
|
+
errors=extraction_errors + validation_errors,
|
|
129
|
+
asm_manifest=asm,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def print_human(result: InspectionResult) -> None:
|
|
134
|
+
print(f"File: {result.path}")
|
|
135
|
+
if not result.has_asm:
|
|
136
|
+
print("ASM metadata: missing")
|
|
137
|
+
else:
|
|
138
|
+
print("ASM metadata: found")
|
|
139
|
+
print(f"Service: {result.asm_service_id or 'unknown'}")
|
|
140
|
+
print(f"Taxonomy: {result.asm_taxonomy or 'unknown'}")
|
|
141
|
+
print(f"Schema validation: {'pass' if result.valid_asm else 'fail'}")
|
|
142
|
+
if result.missing_recommended_fields:
|
|
143
|
+
print("Missing recommended value fields: " + ", ".join(result.missing_recommended_fields))
|
|
144
|
+
else:
|
|
145
|
+
print("Recommended value fields: complete")
|
|
146
|
+
|
|
147
|
+
for warning in result.warnings:
|
|
148
|
+
print(f"Warning: {warning}")
|
|
149
|
+
for error in result.errors:
|
|
150
|
+
print(f"Error: {error}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main(argv: list[str] | None = None) -> int:
|
|
154
|
+
parser = argparse.ArgumentParser(description="Validate ASM metadata embedded in MCP Registry server.json files")
|
|
155
|
+
parser.add_argument("server_json", help="Path to server.json")
|
|
156
|
+
parser.add_argument("--json", action="store_true", help="Print machine-readable inspection JSON")
|
|
157
|
+
parser.add_argument("--write-out", help="Write extracted ASM manifest to this .asm.json path")
|
|
158
|
+
args = parser.parse_args(argv)
|
|
159
|
+
|
|
160
|
+
result = inspect_server_json(args.server_json)
|
|
161
|
+
if args.write_out and result.asm_manifest and result.valid_asm:
|
|
162
|
+
out = Path(args.write_out)
|
|
163
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
out.write_text(json.dumps(result.asm_manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
165
|
+
|
|
166
|
+
if args.json:
|
|
167
|
+
print(json.dumps(result.as_dict(), indent=2, ensure_ascii=False))
|
|
168
|
+
else:
|
|
169
|
+
print_human(result)
|
|
170
|
+
|
|
171
|
+
if result.errors:
|
|
172
|
+
return 1
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
raise SystemExit(main())
|