funccall

Generate JSON schemas from Python functions, call functions from LLM tool-use requests, and convert schemas back to callables

Function calling

Function to schema

Many LLMs do function calling (aka tool use) by taking advantage of JSON schema.

We’ll use docments to make getting JSON schema from Python functions as ergonomic as possible. Each parameter (and the return value) should have a type, and a docments comment with the description of what it is. Here’s an example:

def silly_sum(
    a:int, # First thing to sum
    b:int=1, # Second thing to sum
    c:list[int]=None, # A pointless argument
) -> int: # The sum of the inputs
    "Adds a + b."
    return a + b

This is what docments makes of that:

d = docments(silly_sum, full=True)
d
{ 'a': { 'anno': <class 'int'>,
         'default': <class 'inspect._empty'>,
         'docment': 'First thing to sum'},
  'b': {'anno': <class 'int'>, 'default': 1, 'docment': 'Second thing to sum'},
  'c': {'anno': list[int], 'default': None, 'docment': 'A pointless argument'},
  'return': { 'anno': <class 'int'>,
              'default': <class 'inspect._empty'>,
              'docment': 'The sum of the inputs'}}

Note that this is an AttrDict so we can treat it like an object, or a dict:

d.a.docment, d['a']['anno']
('First thing to sum', int)

_param extracts a parameter’s description and default from its docments information. It omits the default when the parameter has none:

n,o = first(d.items())
print(n,'//', o)
_param(o)
a // {'docment': 'First thing to sum', 'anno': <class 'int'>, 'default': <class 'inspect._empty'>}
{'description': 'First thing to sum'}
n,o
('a',
 {'docment': 'First thing to sum', 'anno': int, 'default': inspect._empty})
d
{ 'a': { 'anno': <class 'int'>,
         'default': <class 'inspect._empty'>,
         'docment': 'First thing to sum'},
  'b': {'anno': <class 'int'>, 'default': 1, 'docment': 'Second thing to sum'},
  'c': {'anno': list[int], 'default': None, 'docment': 'A pointless argument'},
  'return': { 'anno': <class 'int'>,
              'default': <class 'inspect._empty'>,
              'docment': 'The sum of the inputs'}}

source

union_schema

def union_schema(
    t, defs:NoneType=None
):

Schema for union type t: its schema-representable members, unwrapped if only one, anyOf otherwise


source

handle_type

def handle_type(
    t, defs
):

Convert a type annotation to JSON Schema

handle_type raises TypeError for missing annotations, bare object and Any. union_schema keeps the members it can represent. For example, str|object produces a string schema. A union with no representable members raises an error:

test_eq(union_schema(str|object), {'type': 'string'})
test_eq(union_schema(int|str), {'anyOf': [{'type': 'integer'}, {'type': 'string'}]})
test_fail(lambda: union_schema(object|Any), contains="No schema-representable member")
test_is(_schemable(object), None)
handle_type(int, None), handle_type(Path, None)
({'type': 'integer'}, {'type': 'string', 'format': 'Path'})

A fixed-length tuple such as tuple[int, str] becomes an array schema. prefixItems specifies each position’s type. minItems and maxItems specify the length.

The schema also includes items. It uses the common type when every position has the same type, or anyOf with the distinct types otherwise.

# gemini expect `items` to be defined for arrays
handle_type(list, None), handle_type(tuple[str], None), handle_type(set[str], None)
({'type': 'array', 'items': {'type': 'string'}},
 {'type': 'array',
  'prefixItems': [{'type': 'string'}],
  'items': {'type': 'string'},
  'minItems': 1,
  'maxItems': 1},
 {'type': 'array', 'items': {'type': 'string'}, 'uniqueItems': True})

Check unsupported annotations and unions:

test_fail(lambda: handle_type(object, None), contains="Can't make a schema")
test_eq(handle_type(str|object, None), {'type': 'string'})
test_eq(handle_type(int|str, None), {'anyOf': [{'type': 'integer'}, {'type': 'string'}]})
test_fail(lambda: handle_type(object|Any, None), contains="No schema-representable member")
handle_type(dict, None), handle_type(dict[str,str], None)
({'type': 'object'},
 {'type': 'object', 'additionalProperties': {'type': 'string'}})
def _example_new_unioin(opt_tup: str | None): pass

d = docments(_example_new_unioin, full=True)
anno1 = first(d.items())[1].anno
(anno1, get_origin(anno1), get_args(anno1))
(str | None, types.UnionType, (str, NoneType))
def _example_old_union(opt_tup: Union[str, type(None)] =None): pass

d = docments(_example_old_union, full=True)
anno2 = first(d.items())[1].anno
(anno2, get_origin(anno2), get_args(anno2))
(typing.Optional[str], typing.Union, (str, NoneType))

We treat non-specialised arrays as arrays of strs, since some LLMs require a subtype:

test_eq(handle_type(list, {}), {'type': 'array', 'items': {'type': 'string'}})
test_eq(handle_type(set, {}), {'type': 'array', 'items': {'type': 'string'}, 'uniqueItems': True})
# Test primitive types
defs = {}
assert handle_type(int, defs) == {'type': 'integer'}
assert handle_type(str, defs) == {'type': 'string'}
assert handle_type(bool, defs) == {'type': 'boolean'}
assert handle_type(float, defs) == {'type': 'number'}

# Test custom class
class TestClass:
    def __init__(self, x: int, y: int): store_attr()

result = handle_type(TestClass, defs)
assert result == {'$ref': '#/$defs/TestClass'}
assert 'TestClass' in defs
assert defs['TestClass']['type'] == 'object'
assert 'properties' in defs['TestClass']

# tuple[int, ...] should produce array with items, not prefixItems
test_eq(handle_type(tuple[int, ...], {}), {'type': 'array', 'items': {'type': 'integer'}})
# Test primitive types in containers
test_eq(handle_type(list[int], defs), {'type': 'array', 'items': {'type': 'integer'}})
test_eq(handle_type(tuple[str], defs), {'type': 'array', 'prefixItems': [{'type': 'string'}], 'items': {'type': 'string'}, 'minItems': 1, 'maxItems': 1})
test_eq(handle_type(set[str], defs), dict(type='array', items={'type': 'string'}, uniqueItems=True))
test_eq(handle_type(dict[str,bool], defs), {'type': 'object', 'additionalProperties': {'type': 'boolean'}})
result = handle_type(list[TestClass], defs)
assert result == {'type': 'array', 'items': {'$ref': '#/$defs/TestClass'}}
assert 'TestClass' in defs

# Test complex nested structure
ComplexType = dict[str, list[TestClass]]
result = handle_type(dict[str, list[TestClass]], defs)
assert result == {'type': 'object', 'additionalProperties': {'type': 'array', 'items': {'$ref': '#/$defs/TestClass'}}}
# Test processing of a required integer property
props, req = {}, {}
class TestClass:
    "Test class"
    def __init__(
        self,
        x: int, # First thing
        y: list[float], # Second thing
        z: str = "default", # Third thing
    ): store_attr()

d = docments(TestClass, full=True)
_process_property('x', d.x, props, req, defs)
assert 'x' in props
assert props['x']['type'] == 'integer'
assert 'x' in req

# Test processing of a required list property
_process_property('y', d.y, props, req, defs)
assert 'y' in props
assert props['y']['type'] == 'array'
assert props['y']['items']['type'] == 'number'
assert 'y' in req

# Test processing of an optional string property with default
_process_property('z', d.z, props, req, defs)
assert 'z' in props
assert props['z']['type'] == 'string'
assert props['z']['default'] == "default"
assert 'z' not in req

source

get_schema

def get_schema(
    f:Union[<built-in function callable>, dict], # Function to get schema for
    pname:str='input_schema', # Key name for parameters
    evalable:bool=False, # stringify defaults that can't be literal_eval'd?
    skip_hidden:bool=False, # skip parameters starting with '_'?
    name:NoneType=None, # Override function name (useful for dotted paths like 'obj.method')
)->dict: # {'name':..., 'description':..., pname:...}

Generate JSON schema for a class, function, or method

get_schema(get_schema)
{'name': 'get_schema',
 'description': "Generate JSON schema for a class, function, or method\n\nReturns:\n- {'name':..., 'description':..., pname:...} (type: object)",
 'input_schema': {'type': 'object',
  'properties': {'f': {'description': 'Function to get schema for',
    'anyOf': [{'type': 'object'}, {'type': 'object'}]},
   'pname': {'description': 'Key name for parameters',
    'default': 'input_schema',
    'type': 'string'},
   'evalable': {'description': "stringify defaults that can't be literal_eval'd?",
    'default': False,
    'type': 'boolean'},
   'skip_hidden': {'description': "skip parameters starting with '_'?",
    'default': False,
    'type': 'boolean'},
   'name': {'description': "Override function name (useful for dotted paths like 'obj.method')",
    'default': None,
    'type': 'null'}},
  'required': ['f']}}
def f(
    o:dict, # the o
    q:tuple[int,str],
    p:str|list[str] = 'a',
): "dict function"
s = get_schema(f)
test_eq(s['name'], 'f')
inpp = s['input_schema']['properties']
test_eq(inpp['o'], {'type': 'object', 'description': 'the o'})
test_eq(inpp['q'], dict(type='array', description='', prefixItems=[{'type': 'integer'}, {'type': 'string'}], items=dict(anyOf=[{'type': 'integer'}, {'type': 'string'}]), minItems=2, maxItems=2))
test_eq(inpp['p'], dict(description='', default='a', anyOf=[{'type': 'string'}, {'type': 'array', 'items': {'type': 'string'}}]))
s
{'name': 'f',
 'description': 'object function',
 'input_schema': {'type': 'object',
  'properties': {'o': {'description': 'the o', 'type': 'object'},
   'q': {'description': '',
    'type': 'array',
    'prefixItems': [{'type': 'integer'}, {'type': 'string'}],
    'items': {'anyOf': [{'type': 'integer'}, {'type': 'string'}]},
    'minItems': 2,
    'maxItems': 2},
   'p': {'description': '',
    'default': 'a',
    'anyOf': [{'type': 'string'},
     {'type': 'array', 'items': {'type': 'string'}}]}},
  'required': ['o', 'q']}}
def _noanno(x, y:int=0):
    "Docs"
    return x
test_fail(lambda: get_schema(_noanno), contains="Parameter 'x': Missing type annotation")
class ClassA:
    "I am a class"
    def f(self, a:int): # That is `a`
        "Do a thing"
        return 1
    def __call__(self, b:str): # That is `b`
        "Do another thing"
        return 2

ca = ClassA()
ca.f(2)
1
get_schema(ca.f)
{'name': 'f',
 'description': 'Do a thing',
 'input_schema': {'type': 'object',
  'properties': {'a': {'description': 'That is `a`', 'type': 'integer'}},
  'required': ['a']}}
get_schema(ca)
{'name': '__call__',
 'description': 'Do another thing',
 'input_schema': {'type': 'object',
  'properties': {'b': {'description': 'That is `b`', 'type': 'string'}},
  'required': ['b']}}

Usage examples

Putting this all together, we can now test getting a schema from silly_sum. The tool use spec doesn’t support return annotations directly, so we put that in the description instead.

s = get_schema(silly_sum)
desc = s.pop('description')
print(desc)
s
Adds a + b.

Returns:
- The sum of the inputs (type: integer)
{'name': 'silly_sum',
 'input_schema': {'type': 'object',
  'properties': {'a': {'description': 'First thing to sum', 'type': 'integer'},
   'b': {'description': 'Second thing to sum',
    'default': 1,
    'type': 'integer'},
   'c': {'description': 'A pointless argument',
    'default': None,
    'type': 'array',
    'items': {'type': 'integer'}}},
  'required': ['a']}}

This also works with string annotations, e.g:

def silly_test(
    a: 'int',  # quoted type hint
)->int:
    "Mandatory docstring"
    return a

get_schema(silly_test)
{'name': 'silly_test',
 'description': 'Mandatory docstring\n\nReturns:\n- type: integer',
 'input_schema': {'type': 'object',
  'properties': {'a': {'description': 'quoted type hint', 'type': 'integer'}},
  'required': ['a']}}

This also works with instance methods:

class Dummy:
    def sums(
        self,
        a:int,  # First thing to sum
        b:int=1 # Second thing to sum
    ): # The sum of the inputs
        "Adds a + b."
        print(f"Finding the sum of {a} and {b}")
        return a + b

get_schema(Dummy.sums)
{'name': 'sums',
 'description': 'Adds a + b.',
 'input_schema': {'type': 'object',
  'properties': {'a': {'description': 'First thing to sum', 'type': 'integer'},
   'b': {'description': 'Second thing to sum',
    'default': 1,
    'type': 'integer'}},
  'required': ['a']}}

get_schema also handles more complicated structures such as nested classes. This is useful for things like structured outputs.

class Turn:
    "Turn between two speakers"
    def __init__(
        self,
        speaker_a:str, # First speaker's message
        speaker_b:str,  # Second speaker's message
    ): store_attr()

class Conversation:
    "A conversation between two speakers"
    def __init__(
        self,
        turns:list[Turn], # Turns of the conversation
    ): store_attr()

get_schema(Conversation)
{'name': 'Conversation',
 'description': 'A conversation between two speakers',
 'input_schema': {'type': 'object',
  'properties': {'turns': {'description': 'Turns of the conversation',
    'type': 'array',
    'items': {'$ref': '#/$defs/Turn'}}},
  'title': 'Conversation',
  'required': ['turns'],
  '$defs': {'Turn': {'type': 'object',
    'properties': {'speaker_a': {'description': "First speaker's message",
      'type': 'string'},
     'speaker_b': {'description': "Second speaker's message",
      'type': 'string'}},
    'title': 'Turn',
    'required': ['speaker_a', 'speaker_b']}}}}
class DictConversation:
    "A conversation between two speakers"
    def __init__(
        self,
        turns:dict[str,Turn], # dictionary of topics and the Turns of the conversation
    ): store_attr()

get_schema(DictConversation)
{'name': 'DictConversation',
 'description': 'A conversation between two speakers',
 'input_schema': {'type': 'object',
  'properties': {'turns': {'description': 'dictionary of topics and the Turns of the conversation',
    'type': 'object',
    'additionalProperties': {'type': 'object'}}},
  'title': 'DictConversation',
  'required': ['turns']}}
class SetConversation:
    "A conversation between two speakers"
    def __init__(
        self,
        turns:set[Turn], # the unique Turns of the conversation
    ): store_attr()

get_schema(SetConversation)
{'name': 'SetConversation',
 'description': 'A conversation between two speakers',
 'input_schema': {'type': 'object',
  'properties': {'turns': {'description': 'the unique Turns of the conversation',
    'type': 'array',
    'items': {'$ref': '#/$defs/Turn'},
    'uniqueItems': True}},
  'title': 'SetConversation',
  'required': ['turns'],
  '$defs': {'Turn': {'type': 'object',
    'properties': {'speaker_a': {'description': "First speaker's message",
      'type': 'string'},
     'speaker_b': {'description': "Second speaker's message",
      'type': 'string'}},
    'title': 'Turn',
    'required': ['speaker_a', 'speaker_b']}}}}

Additional get_schema() Test Cases

Union types are represented in JSON Schema using anyOf.

IntPair = tuple[int, int]
def _union_test(opt_tup: Union[IntPair, str, int]=None):
    "Mandatory docstring"
    return ""
get_schema(_union_test)
{'name': '_union_test',
 'description': 'Mandatory docstring',
 'input_schema': {'type': 'object',
  'properties': {'opt_tup': {'description': '',
    'default': None,
    'anyOf': [{'type': 'array',
      'prefixItems': [{'type': 'integer'}, {'type': 'integer'}],
      'items': {'type': 'integer'},
      'minItems': 2,
      'maxItems': 2},
     {'type': 'string'},
     {'type': 'integer'}]}}}}

The new (Python 3.10+) union syntax can also be used, producing an equivalent schema.

def _new_union_test(opt_tup: IntPair | str | int =None):
    "Mandatory docstring"
    pass
get_schema(_new_union_test)
{'name': '_new_union_test',
 'description': 'Mandatory docstring',
 'input_schema': {'type': 'object',
  'properties': {'opt_tup': {'description': '',
    'default': None,
    'anyOf': [{'type': 'array',
      'prefixItems': [{'type': 'integer'}, {'type': 'integer'}],
      'items': {'type': 'integer'},
      'minItems': 2,
      'maxItems': 2},
     {'type': 'string'},
     {'type': 'integer'}]}}}}

Optional is a special case of union types, limited to two types, one of which is None (mapped to null in JSON schema):

def _optional_test(opt_tup: Optional[IntPair]=None):
    "Mandatory docstring"
    pass
get_schema(_optional_test)
{'name': '_optional_test',
 'description': 'Mandatory docstring',
 'input_schema': {'type': 'object',
  'properties': {'opt_tup': {'description': '',
    'default': None,
    'anyOf': [{'type': 'array',
      'prefixItems': [{'type': 'integer'}, {'type': 'integer'}],
      'items': {'type': 'integer'},
      'minItems': 2,
      'maxItems': 2},
     {'type': 'null'}]}}}}
def _param_union_test(items: list[str] | None = None):
    "Test parameterized container in union"
    pass
get_schema(_param_union_test)
{'name': '_param_union_test',
 'description': 'Test parameterized container in union',
 'input_schema': {'type': 'object',
  'properties': {'items': {'description': '',
    'default': None,
    'anyOf': [{'type': 'array', 'items': {'type': 'string'}},
     {'type': 'null'}]}}}}

Containers support parameterized types such as List[int] and raw types such as List:

def _list_test(l: List[int]):
    "Mandatory docstring"
    pass
get_schema(_list_test)
{'name': '_list_test',
 'description': 'Mandatory docstring',
 'input_schema': {'type': 'object',
  'properties': {'l': {'description': '',
    'type': 'array',
    'items': {'type': 'integer'}}},
  'required': ['l']}}
def _raw_list_test(l: List):
    "Mandatory docstring"
    pass
get_schema(_raw_list_test)
{'name': '_raw_list_test',
 'description': 'Mandatory docstring',
 'input_schema': {'type': 'object',
  'properties': {'l': {'description': '',
    'type': 'array',
    'items': {'type': 'string'}}},
  'required': ['l']}}

The same applies to dictionary, which can similarly be parameterized with key/value types or specified as a raw type.

def _dict_test(d: Dict[str, int]):
    "Mandatory docstring"
    pass
get_schema(_dict_test)
{'name': '_dict_test',
 'description': 'Mandatory docstring',
 'input_schema': {'type': 'object',
  'properties': {'d': {'description': '',
    'type': 'object',
    'additionalProperties': {'type': 'integer'}}},
  'required': ['d']}}
def _raw_dict_test(d: Dict):
    "Mandatory docstring"
get_schema(_raw_dict_test)
{'name': '_raw_dict_test',
 'description': 'Mandatory docstring',
 'input_schema': {'type': 'object',
  'properties': {'d': {'description': '', 'type': 'object'}},
  'required': ['d']}}
def _path_test(path: Path = Path('.')):
    "Mandatory docstring"
get_schema(_path_test)
{'name': '_path_test',
 'description': 'Mandatory docstring',
 'input_schema': {'type': 'object',
  'properties': {'path': {'description': '',
    'default': Path('.'),
    'type': 'string',
    'format': 'Path'}}}}

Schemas that need to be converted using ast.literal_eval will fail with non-primitive defaults:

test_fail(lambda: ast.literal_eval(str(get_schema(_path_test))), exc=ValueError)

Use evalable to have those defaults stringified:

def _path_test(path: Path = Path('.')):
    "Mandatory docstring"
get_schema(_path_test, evalable=True)
{'name': '_path_test',
 'description': 'Mandatory docstring',
 'input_schema': {'type': 'object',
  'properties': {'path': {'description': '',
    'default': '.',
    'type': 'string',
    'format': 'Path'}}}}

Use skip_hidden to exclude parameters starting with _ from the schema:

def test_hidden(a: int, _internal: str = "x"):
    "Test func"
    pass

get_schema(test_hidden, skip_hidden=True)  # should exclude _internal
{'name': 'test_hidden',
 'description': 'Test func',
 'input_schema': {'type': 'object',
  'properties': {'a': {'description': '', 'type': 'integer'}},
  'required': ['a']}}
get_schema(test_hidden)
{'name': 'test_hidden',
 'description': 'Test func',
 'input_schema': {'type': 'object',
  'properties': {'a': {'description': '', 'type': 'integer'},
   '_internal': {'description': '', 'default': 'x', 'type': 'string'}},
  'required': ['a']}}
def _ret_list_test(a: int) -> list[str]:
    "Mandatory docstring"

s = get_schema(_ret_list_test)
assert 'type: array[string]' in s['description']
Cmd = str | list[str]

def _cust_type(a: Cmd): "Mandatory docstring"

s = get_schema(_cust_type)
s
{'name': '_cust_type',
 'description': 'Mandatory docstring',
 'input_schema': {'type': 'object',
  'properties': {'a': {'description': '',
    'anyOf': [{'type': 'string'},
     {'type': 'array', 'items': {'type': 'string'}}]}},
  'required': ['a']}}

Tool Calling

mk_ns creates a dictionary of functions that the LLM can call. Supply the allowed functions rather than your whole namespace. call_func uses this dictionary to resolve a tool name and call it with the supplied arguments.


source

mk_ns

def mk_ns(
    fs
):
def sums(a, b): return a + b
ns = mk_ns(sums)
ns
{'sums': <function __main__.sums(a, b)>}
ns['sums'](1, 2)
3

source

coerce_inputs

def coerce_inputs(
    func, inputs
):

Coerce inputs based on function type annotations, repairing common LLM container mistakes

ca = ClassA()

source

resolve_nm

def resolve_nm(
    nm, ns
):
test_eq(resolve_nm('ca.f', globals()), ca.f)

source

get_schema_nm

def get_schema_nm(
    nm:str, ns, dot2dash:bool=False, **kwargs
):

Get schema for symbol nm in namespace ns, preserving the full dotted name

schema = get_schema_nm('ca.f', locals())
test_eq(schema['name'], 'ca.f')
schema
{'name': 'ca.f',
 'description': 'Do a thing',
 'input_schema': {'type': 'object',
  'properties': {'a': {'description': 'That is `a`', 'type': 'integer'}},
  'required': ['a']}}

source

call_func

def call_func(
    fc_name, fc_inputs, ns, raise_on_err:bool=True
):

Call the function fc_name with the given fc_inputs using namespace ns.

Call a tool by name with its arguments:

call_func('sums', {'a': 1, 'b': 2}, ns=[sums])
3
assert "unsupported operand type(s) for +: 'int' and 'str'" in call_func('sums', {'a': 1, 'b': '3'}, ns=ns, raise_on_err=False)
test_fail(call_func, args=['sums', {'a': 1, 'b': '3'}], kwargs={'ns': ns})

To use a type that accepts a str constructor argument, add it to custom_types. call_func converts strings for parameters annotated with that type. Here it constructs Path arguments:

def path_test(
    a: Path,  # a type hint
    b: Path   # b type hint
):
    "Mandatory docstring"
    return a/b

test_eq(call_func('path_test', {'a': '/home', 'b': 'user'}, ns=[path_test]), Path('/home/user'))
test_eq(call_func('ca.f', {'a': 5}, ns=globals()), 1)

Sometimes LLMs accidentally use a str instead of a real dict/list, or pass {} instead of a list. We coerce these:

def list_test(a:list[str], b:list[str]=None, c:str|list[str]='x'): return a,b,c
test_eq(call_func('list_test', {'a': '["x","y"]', 'b': None, 'c': 'z'}, ns=[list_test]), (['x','y'], None, 'z'))
test_eq(call_func('list_test', {'a': {}, 'b': 'foo'}, ns=[list_test]), ([], ['foo'], 'x'))

Async function calling

async def asums(a, b): return a + b
ns = mk_ns(asums)
ns
{'asums': <function __main__.asums(a, b)>}

source

call_func_async

async def call_func_async(
    fc_name, fc_inputs, ns, raise_on_err:bool=True
):

Awaits the function fc_name with the given fc_inputs using namespace ns.

Testing async call_func_async both with sync and async functions.

test_eq(await call_func_async('asums', {'a': 1, 'b': 2}, ns=[asums]), 3)
test_eq(await call_func_async('sums', {'a': 1, 'b': 2}, ns=[sums]), 3)
r = await call_func_async('asums', {'a': 1, 'b': '2'}, ns=[asums], raise_on_err=False)
assert "unsupported operand type(s) for +: 'int' and 'str'" in r
ex = False
try: await call_func_async('asums', {'a': 1, 'b': '2'}, ns=[asums], raise_on_err=True)
except: ex = True
assert ex
class B:
    async def g(self, x:int): return x*2

b = B()
res = await call_func_async('b.g', {'x': 5}, ns=globals())
test_eq(res, 10)
async def list_test(a:list, b:list[str]=None, c:str|list[str]='x'): return a,b,c
test_eq(await call_func_async('list_test', {'a': '["x","y"]', 'b': None, 'c': 'z'}, ns=[list_test]), (['x','y'], None, 'z'))

Schema to function


source

mk_param

def mk_param(
    orig, props, req, pynm:NoneType=None
):

Create a Parameter for orig with schema props

tool = dict2obj(dict(
    description='Find real-…',
    inputSchema={
        '$schema': 'http://json-schema.org/draft-07/schema#',
        **dict(
            additionalProperties=False,
            properties=dict(
                language=dict(description='Filter by …', items={'type': 'string'}, type='array'),
                matchCase=dict(default=False, description='Whether th…', type='boolean'),
                path=dict(description='Filter by …', type='string'),
                query=dict(description='The litera…', type='string'),
                useRegexp=dict(default=False, description='Whether to…', type='boolean')),
            required=['query'],
            type='object')},
    name='searchGitHub'))
props, req = tool.inputSchema['properties'], tool.inputSchema['required']
list(props)
['language', 'matchCase', 'path', 'query', 'useRegexp']
props.matchCase
{'default': False, 'description': 'Whether th…', 'type': 'boolean'}
p = mk_param('query', props.query, req)
p, p.kind
(<Parameter "query: str">, <_ParameterKind.POSITIONAL_OR_KEYWORD: 1>)
p = mk_param('language', props.language, req)
p, p.kind
(<Parameter "language: list[str] = None">, <_ParameterKind.KEYWORD_ONLY: 3>)

source

schema2sig

def schema2sig(
    tool
):

Convert json schema tool to a Signature

schema2sig(tool)
<Signature (query: str, *, language: list[str] = None, matchCase: bool = False, path: str = None, useRegexp: bool = False)>

source

mk_tool

def mk_tool(
    dispfn, tool
):

Create a callable function from a JSON schema tool definition

mk_tool creates a Python callable from a JSON Schema tool definition. MCP clients can use it to expose remote tools as functions. The callable has a signature, annotations and a docstring for introspection and autocomplete.

def dispatch_eg(name, **kwargs): return f"Called {name} with {kwargs}"

fn = mk_tool(dispatch_eg, tool)
fn('hello', path='src/')
"Called searchGitHub with {'query': 'hello', 'path': 'src/'}"
tool_hy = dict2obj(dict(name='run', description='Run command', inputSchema=dict(type='object', properties={'cmd': {'type': 'string'}, 'approval-policy': {'type': 'string', 'default': 'never'}}, required=['cmd'])))

sig = schema2sig(tool_hy)
test_eq(list(sig.parameters), ['cmd', 'approval_policy'])
test_eq(sig.parameters['approval_policy'].default, 'never')
test_eq(sig.parameters['approval_policy'].kind, Parameter.KEYWORD_ONLY)
seen = []
def disp(name, **kwargs):
    seen.append((name, kwargs))
    return kwargs

fn = mk_tool(disp, tool_hy)
test_eq(fn('ls', approval_policy='never'), {'cmd': 'ls', 'approval-policy': 'never'})
test_eq(seen[-1], ('run', {'cmd': 'ls', 'approval-policy': 'never'}))
tool_req_hy = dict2obj(dict(name='run', description='Run command', inputSchema=dict(type='object', properties={'approval-policy': {'type': 'string'}, 'cmd': {'type': 'string'}}, required=['approval-policy', 'cmd'])))

seen = []
fn = mk_tool(disp, tool_req_hy)
fn('never', 'ls')
test_eq(seen[-1], ('run', {'approval-policy': 'never', 'cmd': 'ls'}))
tool_collision = dict2obj(dict(name='run', description='Run command', inputSchema=dict(type='object', properties={'approval-policy': {'type': 'string'}, 'approval_policy': {'type': 'string'}})))

test_fail(lambda: schema2sig(tool_collision), contains='collision')

Parameter descriptions use Annotated metadata that docments can read. This lets get_schema recover descriptions from an mk_tool callable, for example when passing an MCP tool to a chat API:

rt_tool = dict2obj(dict(name='fahrenheit', description='Convert Celsius to Fahrenheit',
    inputSchema=dict(type='object', required=['celsius'],
        properties=dict(celsius=dict(description='Temperature to convert', type='number')))))
test_eq(get_schema(mk_tool(noop, rt_tool), pname='inputSchema'), dict(rt_tool))
test_eq(_py_nm(''), '_')