toolschema 1.0.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.
toolschema/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """toolschema — function to JSON Schema for AI agent tools."""
2
+
3
+ from toolschema._decorator import tool
4
+ from toolschema._fields import Field
5
+ from toolschema._introspect import schema
6
+ from toolschema._ir import ToolDefinition
7
+ from toolschema._standard import JSONSchemaOptions, StandardSchemaHost
8
+ from toolschema._validate import (
9
+ ValidationFailure,
10
+ ValidationIssue,
11
+ ValidationResult,
12
+ ValidationSuccess,
13
+ )
14
+
15
+ __version__ = "1.0.0"
16
+
17
+ __all__ = [
18
+ "tool",
19
+ "schema",
20
+ "Field",
21
+ "ToolDefinition",
22
+ "JSONSchemaOptions",
23
+ "StandardSchemaHost",
24
+ "ValidationFailure",
25
+ "ValidationIssue",
26
+ "ValidationResult",
27
+ "ValidationSuccess",
28
+ "__version__",
29
+ ]
toolschema/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from toolschema.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ from collections.abc import Callable
5
+ from typing import Any, TypeVar, overload
6
+
7
+ from toolschema._introspect import ToolMeta
8
+
9
+ F = TypeVar("F", bound=Callable[..., Any])
10
+
11
+
12
+ @overload
13
+ def tool(fn: F) -> F: ...
14
+
15
+
16
+ @overload
17
+ def tool(
18
+ *,
19
+ name: str | None = None,
20
+ description: str | None = None,
21
+ ) -> Callable[[F], F]: ...
22
+
23
+
24
+ def tool(
25
+ fn: F | None = None,
26
+ *,
27
+ name: str | None = None,
28
+ description: str | None = None,
29
+ ) -> F | Callable[[F], F]:
30
+ """Mark a function as a tool and optionally override name or description."""
31
+
32
+ def decorator(func: F) -> F:
33
+ @functools.wraps(func)
34
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
35
+ return func(*args, **kwargs)
36
+
37
+ wrapper._toolschema = ToolMeta( # type: ignore[attr-defined]
38
+ name=name,
39
+ description=description,
40
+ )
41
+ wrapper.__wrapped__ = func # type: ignore[attr-defined]
42
+ return wrapper # type: ignore[return-value]
43
+
44
+ if fn is not None:
45
+ return decorator(fn)
46
+ return decorator
toolschema/_fields.py ADDED
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Field:
9
+ """Metadata for Annotated type hints, mapped to JSON Schema constraints."""
10
+
11
+ description: str | None = None
12
+ min_length: int | None = None
13
+ max_length: int | None = None
14
+ ge: int | float | None = None
15
+ le: int | float | None = None
16
+ gt: int | float | None = None
17
+ lt: int | float | None = None
18
+ pattern: str | None = None
19
+
20
+
21
+ def field_to_schema_extras(field: Field) -> dict[str, Any]:
22
+ """Convert Field metadata to JSON Schema keyword fragments."""
23
+ extras: dict[str, Any] = {}
24
+ if field.description is not None:
25
+ extras["description"] = field.description
26
+ if field.min_length is not None:
27
+ extras["minLength"] = field.min_length
28
+ if field.max_length is not None:
29
+ extras["maxLength"] = field.max_length
30
+ if field.ge is not None:
31
+ extras["minimum"] = field.ge
32
+ if field.le is not None:
33
+ extras["maximum"] = field.le
34
+ if field.gt is not None:
35
+ extras["exclusiveMinimum"] = field.gt
36
+ if field.lt is not None:
37
+ extras["exclusiveMaximum"] = field.lt
38
+ if field.pattern is not None:
39
+ extras["pattern"] = field.pattern
40
+ return extras
41
+
42
+
43
+ def extract_annotated_metadata(metadata: tuple[Any, ...]) -> tuple[type[Any], dict[str, Any]]:
44
+ """Extract base type and merged schema extras from Annotated metadata."""
45
+ if not metadata:
46
+ return object, {}
47
+
48
+ base_type = metadata[0] if isinstance(metadata[0], type) else metadata[0]
49
+ extras: dict[str, Any] = {}
50
+
51
+ for item in metadata[1:]:
52
+ if isinstance(item, Field):
53
+ extras.update(field_to_schema_extras(item))
54
+ elif isinstance(item, str):
55
+ if "description" not in extras:
56
+ extras["description"] = item
57
+
58
+ return base_type, extras
59
+
60
+
61
+ def merge_field_into_schema(base_schema: dict[str, Any], extras: dict[str, Any]) -> dict[str, Any]:
62
+ """Merge Field / Annotated metadata into a JSON Schema fragment."""
63
+ if not extras:
64
+ return base_schema
65
+ return {**base_schema, **extras}
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ from importlib import resources
6
+ from pathlib import Path
7
+ from typing import NamedTuple
8
+
9
+
10
+ class ScaffoldResult(NamedTuple):
11
+ root: Path
12
+ package_name: str
13
+ project_name: str
14
+
15
+
16
+ def slugify_package_name(name: str) -> str:
17
+ slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
18
+ if not slug:
19
+ raise ValueError("Project name must contain at least one letter or digit")
20
+ if slug[0].isdigit():
21
+ slug = f"mcp_{slug}"
22
+ return slug
23
+
24
+
25
+ def _render(text: str, *, project_name: str, package_name: str, project_title: str) -> str:
26
+ return (
27
+ text.replace("{{project_name}}", project_name)
28
+ .replace("{{package_name}}", package_name)
29
+ .replace("{{project_title}}", project_title)
30
+ )
31
+
32
+
33
+ def scaffold_mcp_server(target_dir: Path, project_name: str) -> ScaffoldResult:
34
+ """Scaffold a new MCP server project from the packaged template."""
35
+ package_name = slugify_package_name(project_name)
36
+ project_title = project_name.replace("-", " ").replace("_", " ").title()
37
+ root = target_dir.resolve() / project_name
38
+
39
+ if root.exists():
40
+ raise FileExistsError(f"Directory already exists: {root}")
41
+
42
+ template_root = resources.files("toolschema.templates") / "mcp_server"
43
+ _copy_template_tree(template_root, root)
44
+
45
+ for path in root.rglob("*"):
46
+ if not path.is_file():
47
+ continue
48
+ content = path.read_text(encoding="utf-8")
49
+ rendered = _render(
50
+ content,
51
+ project_name=project_name,
52
+ package_name=package_name,
53
+ project_title=project_title,
54
+ )
55
+ path.write_text(rendered, encoding="utf-8")
56
+
57
+ package_dir = root / "src" / "PACKAGE"
58
+ final_package_dir = root / "src" / package_name
59
+ package_dir.rename(final_package_dir)
60
+
61
+ return ScaffoldResult(root=root, package_name=package_name, project_name=project_name)
62
+
63
+
64
+ def _copy_template_tree(source: resources.abc.Traversable, destination: Path) -> None:
65
+ destination.mkdir(parents=True, exist_ok=True)
66
+ for item in source.iterdir():
67
+ if item.is_dir():
68
+ _copy_template_tree(item, destination / item.name)
69
+ else:
70
+ shutil.copyfile(item, destination / item.name)
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from typing import Any, get_type_hints
7
+
8
+ from toolschema._ir import ToolDefinition
9
+ from toolschema._types import JSON_SCHEMA_2020_12, type_to_schema
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ToolMeta:
14
+ """Metadata attached by the @tool decorator."""
15
+
16
+ name: str | None = None
17
+ description: str | None = None
18
+
19
+
20
+ def _unwrap_tool(fn: Callable[..., Any]) -> tuple[Callable[..., Any], ToolMeta | None]:
21
+ meta = getattr(fn, "_toolschema", None)
22
+ if meta is not None:
23
+ wrapped = getattr(fn, "__wrapped__", fn)
24
+ return wrapped, meta
25
+ return fn, None
26
+
27
+
28
+ def _parse_docstring_description(doc: str | None) -> str:
29
+ if not doc:
30
+ return ""
31
+ paragraphs = doc.strip().split("\n\n")
32
+ return paragraphs[0].strip().replace("\n", " ")
33
+
34
+
35
+ def _build_parameters_schema(fn: Callable[..., Any]) -> dict[str, Any]:
36
+ hints = get_type_hints(fn, include_extras=True)
37
+ sig = inspect.signature(fn)
38
+
39
+ properties: dict[str, Any] = {}
40
+ required: list[str] = []
41
+
42
+ for name, param in sig.parameters.items():
43
+ if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
44
+ continue
45
+ if name in ("self", "cls"):
46
+ continue
47
+
48
+ annotation = hints.get(name, Any)
49
+ prop_schema = type_to_schema(annotation)
50
+
51
+ if param.default is not inspect.Parameter.empty:
52
+ prop_schema = {**prop_schema, "default": param.default}
53
+ else:
54
+ required.append(name)
55
+
56
+ properties[name] = prop_schema
57
+
58
+ schema: dict[str, Any] = {
59
+ "$schema": JSON_SCHEMA_2020_12,
60
+ "type": "object",
61
+ "properties": properties,
62
+ "additionalProperties": False,
63
+ }
64
+ if required:
65
+ schema["required"] = required
66
+ return schema
67
+
68
+
69
+ def _build_output_schema(fn: Callable[..., Any]) -> dict[str, Any] | None:
70
+ hints = get_type_hints(fn, include_extras=True)
71
+ return_type = hints.get("return", inspect.Signature.empty)
72
+ if return_type is inspect.Signature.empty or return_type is None or return_type is type(None):
73
+ return None
74
+ return type_to_schema(return_type)
75
+
76
+
77
+ def schema(fn: Callable[..., Any]) -> ToolDefinition:
78
+ """Build a ToolDefinition from a Python function's signature and annotations."""
79
+ target, meta = _unwrap_tool(fn)
80
+
81
+ name = (meta.name if meta and meta.name else None) or target.__name__
82
+ description = (
83
+ meta.description
84
+ if meta and meta.description is not None
85
+ else _parse_docstring_description(target.__doc__)
86
+ )
87
+
88
+ return ToolDefinition(
89
+ name=name,
90
+ description=description,
91
+ parameters=_build_parameters_schema(target),
92
+ output=_build_output_schema(target),
93
+ )
toolschema/_ir.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from toolschema._standard import StandardSchemaHost, build_standard_schema
7
+ from toolschema._validate import ValidationResult, validate_arguments
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ToolDefinition:
12
+ """Intermediate representation of a tool derived from a Python function."""
13
+
14
+ name: str
15
+ description: str
16
+ parameters: dict[str, Any]
17
+ output: dict[str, Any] | None = None
18
+
19
+ def to_json_schema(self) -> dict[str, Any]:
20
+ """Return canonical tool record with JSON Schema 2020-12 parameters."""
21
+ result: dict[str, Any] = {
22
+ "name": self.name,
23
+ "description": self.description,
24
+ "parameters": self.parameters,
25
+ }
26
+ if self.output is not None:
27
+ result["output"] = self.output
28
+ return result
29
+
30
+ def validate(self, args: Any) -> ValidationResult:
31
+ """Validate tool arguments against the canonical parameters schema."""
32
+ return validate_arguments(args, self.parameters)
33
+
34
+ @property
35
+ def standard(self) -> StandardSchemaHost:
36
+ """Standard Schema + Standard JSON Schema protocol host."""
37
+ return build_standard_schema(
38
+ parameters=self.parameters,
39
+ output=self.output,
40
+ validate=self.validate,
41
+ )
42
+
43
+ def to_openai(self, *, strict: bool = False) -> dict[str, Any]:
44
+ from toolschema.adapters.openai import to_openai
45
+
46
+ return to_openai(self, strict=strict)
47
+
48
+ def to_anthropic(self) -> dict[str, Any]:
49
+ from toolschema.adapters.anthropic import to_anthropic
50
+
51
+ return to_anthropic(self)
52
+
53
+ def to_mcp(self, *, inline_refs: bool = True) -> dict[str, Any]:
54
+ from toolschema.adapters.mcp import to_mcp
55
+
56
+ return to_mcp(self, inline_refs=inline_refs)
57
+
58
+ def to_gemini(self) -> dict[str, Any]:
59
+ from toolschema.adapters.gemini import to_gemini
60
+
61
+ return to_gemini(self)
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Any
5
+
6
+
7
+ def strip_canonical_meta(schema: dict[str, Any]) -> dict[str, Any]:
8
+ """Remove JSON Schema dialect metadata not used by provider payloads."""
9
+ result = copy.deepcopy(schema)
10
+ result.pop("$schema", None)
11
+ return result
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Any, Literal
6
+
7
+ from toolschema._schema_utils import strip_canonical_meta
8
+ from toolschema._validate import ValidationResult
9
+
10
+ STANDARD_VERSION = 1
11
+ STANDARD_VENDOR = "toolschema"
12
+
13
+ JSONSchemaTarget = Literal["draft-2020-12", "draft-07", "openapi-3.0"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class JSONSchemaOptions:
18
+ target: JSONSchemaTarget = "draft-2020-12"
19
+ library_options: dict[str, Any] | None = None
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class StandardSchemaProps:
24
+ """Standard Schema + Standard JSON Schema properties for ecosystem interop."""
25
+
26
+ version: int
27
+ vendor: str
28
+ validate: Callable[[Any], ValidationResult]
29
+ json_schema_input: Callable[[JSONSchemaOptions], dict[str, Any]]
30
+ json_schema_output: Callable[[JSONSchemaOptions], dict[str, Any]]
31
+
32
+ def to_mapping(self) -> dict[str, Any]:
33
+ return {
34
+ "version": self.version,
35
+ "vendor": self.vendor,
36
+ "validate": self.validate,
37
+ "jsonSchema": {
38
+ "input": self.json_schema_input,
39
+ "output": self.json_schema_output,
40
+ },
41
+ }
42
+
43
+
44
+ class StandardSchemaHost(Mapping[str, Any]):
45
+ """Mapping host exposing the ``~standard`` Standard Schema protocol key."""
46
+
47
+ def __init__(self, props: StandardSchemaProps) -> None:
48
+ self._props = props
49
+
50
+ def __getitem__(self, key: str) -> Any:
51
+ if key == "~standard":
52
+ return self._props.to_mapping()
53
+ raise KeyError(key)
54
+
55
+ def __iter__(self):
56
+ yield "~standard"
57
+
58
+ def __len__(self) -> int:
59
+ return 1
60
+
61
+
62
+ def build_standard_schema(
63
+ *,
64
+ parameters: dict[str, Any],
65
+ output: dict[str, Any] | None,
66
+ validate: Callable[[Any], ValidationResult],
67
+ ) -> StandardSchemaHost:
68
+ def json_schema_input(
69
+ options: JSONSchemaOptions | dict[str, Any] | None = None,
70
+ ) -> dict[str, Any]:
71
+ opts = _coerce_options(options)
72
+ _ensure_target(opts.target)
73
+ return strip_canonical_meta(parameters)
74
+
75
+ def json_schema_output(
76
+ options: JSONSchemaOptions | dict[str, Any] | None = None,
77
+ ) -> dict[str, Any]:
78
+ opts = _coerce_options(options)
79
+ _ensure_target(opts.target)
80
+ if output is None:
81
+ raise ValueError("Tool has no output schema")
82
+ return strip_canonical_meta(output)
83
+
84
+ props = StandardSchemaProps(
85
+ version=STANDARD_VERSION,
86
+ vendor=STANDARD_VENDOR,
87
+ validate=validate,
88
+ json_schema_input=json_schema_input,
89
+ json_schema_output=json_schema_output,
90
+ )
91
+ return StandardSchemaHost(props)
92
+
93
+
94
+ def _coerce_options(options: JSONSchemaOptions | dict[str, Any] | None) -> JSONSchemaOptions:
95
+ if options is None:
96
+ return JSONSchemaOptions()
97
+ if isinstance(options, JSONSchemaOptions):
98
+ return options
99
+ return JSONSchemaOptions(
100
+ target=options.get("target", "draft-2020-12"),
101
+ library_options=options.get("libraryOptions"),
102
+ )
103
+
104
+
105
+ def _ensure_target(target: str) -> None:
106
+ if target not in {"draft-2020-12", "draft-07", "openapi-3.0"}:
107
+ raise ValueError(f"Unsupported JSON Schema target: {target!r}")
toolschema/_types.py ADDED
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import enum
5
+ import types
6
+ from typing import Annotated, Any, Literal, Union, get_args, get_origin, get_type_hints
7
+
8
+ from toolschema._fields import extract_annotated_metadata, merge_field_into_schema
9
+
10
+ JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema"
11
+
12
+
13
+ def _normalize_pydantic_schema(schema: dict[str, Any]) -> dict[str, Any]:
14
+ """Normalize a Pydantic model_json_schema() payload to a property schema."""
15
+ result = {k: v for k, v in schema.items() if k not in {"$defs", "$schema", "title"}}
16
+ properties = result.get("properties")
17
+ if isinstance(properties, dict):
18
+ result["properties"] = {
19
+ name: {k: v for k, v in prop.items() if k != "title"}
20
+ for name, prop in properties.items()
21
+ if isinstance(prop, dict)
22
+ }
23
+ if "properties" in result:
24
+ result.setdefault("type", "object")
25
+ result.setdefault("additionalProperties", False)
26
+ return result
27
+
28
+
29
+ def _is_typeddict(tp: Any) -> bool:
30
+ try:
31
+ from typing_extensions import is_typeddict
32
+
33
+ return is_typeddict(tp)
34
+ except ImportError:
35
+ return isinstance(tp, type) and hasattr(tp, "__annotations__") and hasattr(tp, "__total__")
36
+
37
+
38
+ def _typeddict_to_schema(tp: type[Any]) -> dict[str, Any]:
39
+ hints = get_type_hints(tp)
40
+ total = getattr(tp, "__total__", True)
41
+ properties = {name: type_to_schema(annotation) for name, annotation in hints.items()}
42
+ required = list(hints.keys()) if total else []
43
+ schema: dict[str, Any] = {
44
+ "type": "object",
45
+ "properties": properties,
46
+ "additionalProperties": False,
47
+ }
48
+ if required:
49
+ schema["required"] = required
50
+ return schema
51
+
52
+
53
+ def _dataclass_to_schema(tp: type[Any]) -> dict[str, Any]:
54
+ hints = get_type_hints(tp)
55
+ properties: dict[str, Any] = {}
56
+ required: list[str] = []
57
+ for field in dataclasses.fields(tp):
58
+ annotation = hints.get(field.name, Any)
59
+ properties[field.name] = type_to_schema(annotation)
60
+ if field.default is dataclasses.MISSING and field.default_factory is dataclasses.MISSING:
61
+ required.append(field.name)
62
+ elif field.default is not dataclasses.MISSING:
63
+ properties[field.name] = {
64
+ **properties[field.name],
65
+ "default": field.default,
66
+ }
67
+ schema: dict[str, Any] = {
68
+ "type": "object",
69
+ "properties": properties,
70
+ "additionalProperties": False,
71
+ }
72
+ if required:
73
+ schema["required"] = required
74
+ return schema
75
+
76
+
77
+ def type_to_schema(tp: Any) -> dict[str, Any]:
78
+ """Convert a Python type annotation to a JSON Schema 2020-12 fragment."""
79
+ origin = get_origin(tp)
80
+ args = get_args(tp)
81
+
82
+ if origin is Annotated:
83
+ base_type, extras = extract_annotated_metadata(args)
84
+ schema = type_to_schema(base_type)
85
+ return merge_field_into_schema(schema, extras)
86
+
87
+ if origin is Union or isinstance(tp, types.UnionType):
88
+ non_none = [a for a in args if a is not type(None)]
89
+ if not non_none:
90
+ return {"type": "null"}
91
+ if len(non_none) == 1 and type(None) in args:
92
+ inner = type_to_schema(non_none[0])
93
+ return {"anyOf": [inner, {"type": "null"}]}
94
+ schemas = [type_to_schema(a) for a in args if a is not type(None)]
95
+ if type(None) in args:
96
+ schemas.append({"type": "null"})
97
+ if len(schemas) == 1:
98
+ return schemas[0]
99
+ return {"anyOf": schemas}
100
+
101
+ if origin is tuple:
102
+ if len(args) == 2 and args[1] is Ellipsis:
103
+ return {"type": "array", "items": type_to_schema(args[0])}
104
+ if args:
105
+ return {
106
+ "type": "array",
107
+ "prefixItems": [type_to_schema(arg) for arg in args],
108
+ "minItems": len(args),
109
+ "maxItems": len(args),
110
+ }
111
+ return {"type": "array"}
112
+
113
+ if origin is list:
114
+ item_type = args[0] if args else Any
115
+ return {"type": "array", "items": type_to_schema(item_type)}
116
+
117
+ if origin is dict:
118
+ if len(args) == 2 and args[0] is str:
119
+ return {
120
+ "type": "object",
121
+ "additionalProperties": type_to_schema(args[1]),
122
+ }
123
+ if not args:
124
+ return {"type": "object"}
125
+ raise TypeError(f"Unsupported dict type (only dict[str, T] supported): {tp!r}")
126
+
127
+ if origin is Literal:
128
+ values = list(args)
129
+ if all(isinstance(v, str) for v in values):
130
+ return {"enum": values}
131
+ if all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in values):
132
+ return {"enum": values}
133
+ if all(isinstance(v, bool) for v in values):
134
+ return {"enum": values}
135
+ return {"enum": values}
136
+
137
+ if isinstance(tp, type) and issubclass(tp, enum.Enum):
138
+ return {"enum": [member.value for member in tp]}
139
+
140
+ if isinstance(tp, type) and _is_typeddict(tp):
141
+ return _typeddict_to_schema(tp)
142
+
143
+ if isinstance(tp, type) and dataclasses.is_dataclass(tp):
144
+ return _dataclass_to_schema(tp)
145
+
146
+ if isinstance(tp, type) and hasattr(tp, "model_json_schema"):
147
+ return _normalize_pydantic_schema(tp.model_json_schema())
148
+
149
+ if tp is str:
150
+ return {"type": "string"}
151
+ if tp is int:
152
+ return {"type": "integer"}
153
+ if tp is float:
154
+ return {"type": "number"}
155
+ if tp is bool:
156
+ return {"type": "boolean"}
157
+ if tp is dict:
158
+ return {"type": "object"}
159
+ if tp is type(None):
160
+ return {"type": "null"}
161
+ if tp is Any:
162
+ return {}
163
+
164
+ raise TypeError(f"Unsupported type annotation: {tp!r}")