langfun 0.1.2.dev202510160805__py3-none-any.whl → 0.1.2.dev202510180803__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.

Potentially problematic release.


This version of langfun might be problematic. Click here for more details.

langfun/__init__.py CHANGED
@@ -66,8 +66,8 @@ from langfun.core import agentic
66
66
  Action = agentic.Action
67
67
  Session = agentic.Session
68
68
 
69
+ from langfun.core import mcp
69
70
  from langfun.core import memories
70
-
71
71
  from langfun.core import modalities
72
72
 
73
73
  Mime = modalities.Mime
langfun/core/__init__.py CHANGED
@@ -40,9 +40,13 @@ from langfun.core.component import context
40
40
  as_context = context
41
41
  use_context = context
42
42
 
43
- # Invoke a callable object asynchronously.
43
+ # Support for async IO.
44
44
  from langfun.core.async_support import invoke_async
45
45
 
46
+ # Adaptors for async function/context manager to sync versions.
47
+ from langfun.core.async_support import invoke_sync
48
+ from langfun.core.async_support import sync_context_manager
49
+
46
50
  # Shortcut function for overriding components attributes, usually for
47
51
  # override settings.
48
52
  from langfun.core.component import use_settings
@@ -14,15 +14,85 @@
14
14
  """Utility for async IO in Langfun."""
15
15
 
16
16
  import asyncio
17
- from typing import Any, Callable
17
+ import contextlib
18
+ from typing import Any, Awaitable, Callable, Iterator
19
+ import anyio
18
20
  import pyglove as pg
19
21
 
20
22
 
21
23
  async def invoke_async(
22
- callable_object: Callable[..., Any], *args, **kwargs
24
+ sync_callable: Callable[..., Any], *args, **kwargs
23
25
  ) -> Any:
24
26
  """Invokes a callable asynchronously with `lf.context` manager enabled."""
25
27
  return await asyncio.to_thread(
26
28
  # Enable `lf.context` manager for async calls.
27
- pg.with_contextual_override(callable_object), *args, **kwargs
29
+ pg.with_contextual_override(sync_callable), *args, **kwargs
28
30
  )
31
+
32
+
33
+ def invoke_sync(
34
+ async_callable: Callable[..., Awaitable[Any]],
35
+ *args,
36
+ **kwargs
37
+ ) -> Any:
38
+ """Invokes a async callable synchronously."""
39
+ async def _invoke():
40
+ return await async_callable(*args, **kwargs)
41
+ invoke_fn = pg.with_contextual_override(_invoke)
42
+ blocking_portal = pg.utils.thread_local_get('__blocking_portal__', None)
43
+ if blocking_portal is None:
44
+ return anyio.run(invoke_fn)
45
+ return blocking_portal.call(invoke_fn)
46
+
47
+
48
+ @contextlib.contextmanager
49
+ def sync_context_manager(
50
+ async_context_manager: contextlib.AbstractAsyncContextManager[Any]
51
+ ) -> Iterator[Any]:
52
+ """Adapts an async context manager to a sync context manager.
53
+
54
+ sync_context_manager installs a blocking portal in current thread to run the
55
+ async context manager in a blocking way. It's useful for running async code in
56
+ sync context managers, e.g. `sync_context_manager` can be nested and share the
57
+ same event loop.
58
+
59
+ Example:
60
+
61
+ ```python
62
+ @contextlib.asynccontextmanager
63
+ async def foo(x):
64
+ try:
65
+ yield x
66
+ finally:
67
+ pass
68
+
69
+ with lf.sync_context_manager(foo(x)) as x
70
+ with lf.sync_context_manager(foo(y)) as y:
71
+ ...
72
+ ```
73
+
74
+ Args:
75
+ async_context_manager: The async context manager to adapt.
76
+
77
+ Yields:
78
+ The value yielded by the async context manager.
79
+ """
80
+ blocking_portal = pg.utils.thread_local_get('__blocking_portal__', None)
81
+ portal_exit_stack = None
82
+
83
+ try:
84
+ if blocking_portal is None:
85
+ portal_exit_stack = contextlib.ExitStack()
86
+ blocking_portal = portal_exit_stack.enter_context(
87
+ anyio.from_thread.start_blocking_portal()
88
+ )
89
+ pg.utils.thread_local_set('__blocking_portal__', blocking_portal)
90
+ context_manager = blocking_portal.wrap_async_context_manager(
91
+ async_context_manager
92
+ )
93
+ with context_manager as value:
94
+ yield value
95
+ finally:
96
+ if portal_exit_stack is not None:
97
+ portal_exit_stack.close()
98
+ pg.utils.thread_local_del('__blocking_portal__')
@@ -13,6 +13,7 @@
13
13
  # limitations under the License.
14
14
 
15
15
  import asyncio
16
+ import contextlib
16
17
  import time
17
18
  import unittest
18
19
 
@@ -34,6 +35,28 @@ class AsyncSupportTest(unittest.TestCase):
34
35
  with pg.contextual_override(z=3):
35
36
  self.assertEqual(asyncio.run(r), 6)
36
37
 
38
+ def test_invoke_sync(self):
39
+ @contextlib.asynccontextmanager
40
+ async def bar(x):
41
+ try:
42
+ yield x
43
+ finally:
44
+ pass
45
+
46
+ async def foo(x, *, y):
47
+ time.sleep(2)
48
+ return x + y + pg.contextual_value('z', 0)
49
+
50
+ with pg.contextual_override(z=3):
51
+ with async_support.sync_context_manager(bar(1)) as x:
52
+ self.assertEqual(x, 1)
53
+ with async_support.sync_context_manager(bar(2)) as y:
54
+ self.assertEqual(y, 2)
55
+ self.assertEqual(async_support.invoke_sync(foo, 1, y=2), 6)
56
+
57
+ with pg.contextual_override(z=2):
58
+ self.assertEqual(async_support.invoke_sync(foo, 1, y=2), 5)
59
+
37
60
 
38
61
  if __name__ == '__main__':
39
62
  unittest.main()
@@ -262,6 +262,7 @@ class ProgressControlTest(unittest.TestCase):
262
262
  with contextlib.redirect_stderr(string_io):
263
263
  ctrl.update(1)
264
264
  ctrl.refresh()
265
+ sys.stderr.flush()
265
266
  self.assertEqual(string_io.getvalue(), '')
266
267
  concurrent.progress_bar = 'tqdm'
267
268
 
@@ -274,6 +275,7 @@ class ProgressControlTest(unittest.TestCase):
274
275
  ctrl.set_status('bar')
275
276
  ctrl.update(10)
276
277
  ctrl.refresh()
278
+ sys.stderr.flush()
277
279
  self.assertEqual(
278
280
  string_io.getvalue(),
279
281
  '\x1b[1m\x1b[31mfoo\x1b[0m: \x1b[34m10% (10/100)\x1b[0m : bar\n'
@@ -288,6 +290,7 @@ class ProgressControlTest(unittest.TestCase):
288
290
  self.assertIsInstance(ctrl, concurrent._TqdmProgressControl)
289
291
  ctrl.update(10)
290
292
  ctrl.refresh()
293
+ sys.stderr.flush()
291
294
  self.assertIn('10/100', string_io.getvalue())
292
295
 
293
296
  tqdm = concurrent.tqdm
@@ -316,6 +319,7 @@ class ProgressBarTest(unittest.TestCase):
316
319
  for _ in concurrent.concurrent_execute(fun, range(5)):
317
320
  concurrent.ProgressBar.refresh()
318
321
  concurrent.ProgressBar.uninstall(bar_id)
322
+ sys.stderr.flush()
319
323
  output_str = string_io.getvalue()
320
324
  self.assertIn('100%', output_str)
321
325
  self.assertIn('5/5', output_str)
@@ -332,7 +336,7 @@ class ProgressBarTest(unittest.TestCase):
332
336
  concurrent.ProgressBar.update(bar_id, 0, status=1)
333
337
  concurrent.ProgressBar.uninstall(bar_id)
334
338
  sys.stderr.flush()
335
- time.sleep(1)
339
+ time.sleep(1)
336
340
  self.assertIn('1/4', string_io.getvalue())
337
341
  # TODO(daiyip): Re-enable once flakiness is fixed.
338
342
  # self.assertIn('2/4', string_io.getvalue())
@@ -564,7 +568,8 @@ class ConcurrentMapTest(unittest.TestCase):
564
568
  fun, [1, 2, 3], timeout=1.5, max_workers=1, show_progress=True
565
569
  )
566
570
  ], key=lambda x: x[0])
567
- string_io.flush()
571
+ sys.stderr.flush()
572
+
568
573
  self.assertEqual( # pylint: disable=g-generic-assert
569
574
  output,
570
575
  [
@@ -592,6 +597,7 @@ class ConcurrentMapTest(unittest.TestCase):
592
597
  show_progress=bar_id, status_fn=lambda p: dict(x=1, y=1)
593
598
  )
594
599
  ], key=lambda x: x[0])
600
+ sys.stderr.flush()
595
601
 
596
602
  self.assertEqual( # pylint: disable=g-generic-assert
597
603
  output,
@@ -50,7 +50,7 @@ class TqdmProgressTrackerTest(unittest.TestCase):
50
50
  string_io = io.StringIO()
51
51
  with contextlib.redirect_stderr(string_io):
52
52
  _ = experiment.run(root_dir, 'new', plugins=[])
53
- sys.stderr.flush()
53
+ sys.stderr.flush()
54
54
  self.assertIn('All: 100%', string_io.getvalue())
55
55
 
56
56
  def test_with_example_ids(self):
@@ -61,7 +61,7 @@ class TqdmProgressTrackerTest(unittest.TestCase):
61
61
  string_io = io.StringIO()
62
62
  with contextlib.redirect_stderr(string_io):
63
63
  _ = experiment.run(root_dir, 'new', example_ids=[1], plugins=[])
64
- sys.stderr.flush()
64
+ sys.stderr.flush()
65
65
  self.assertIn('All: 100%', string_io.getvalue())
66
66
 
67
67
 
@@ -0,0 +1,10 @@
1
+ """Langfun MCP support."""
2
+
3
+ # pylint: disable=g-importing-member
4
+
5
+ from langfun.core.mcp.client import McpClient
6
+ from langfun.core.mcp.session import McpSession
7
+ from langfun.core.mcp.tool import McpTool
8
+ from langfun.core.mcp.tool import McpToolInput
9
+
10
+ # pylint: enable=g-importing-member
@@ -0,0 +1,122 @@
1
+ # Copyright 2025 The Langfun Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """MCP client."""
15
+
16
+ import abc
17
+ from typing import Annotated, Type
18
+
19
+ from langfun.core.mcp import session as mcp_session
20
+ from langfun.core.mcp import tool as mcp_tool
21
+ from mcp.server import fastmcp as fastmcp_lib
22
+ import pyglove as pg
23
+
24
+
25
+ class McpClient(pg.Object):
26
+ """Base class for MCP client.
27
+
28
+ Usage:
29
+
30
+ ```python
31
+
32
+ def tool_use():
33
+ client = lf.mcp.McpClient.from_command('<MCP_CMD>', ['<ARG1>', 'ARG2'])
34
+ tools = client.list_tools()
35
+ tool_cls = tools['<TOOL_NAME>']
36
+
37
+ # Print the python definition of the tool.
38
+ print(tool_cls.python_definition())
39
+
40
+ with client.session() as session:
41
+ return tool_cls(x=1, y=2)(session)
42
+
43
+ async def tool_use_async_version():
44
+ client = lf.mcp.McpClient.from_url('http://localhost:8000/mcp')
45
+ tools = client.list_tools()
46
+ tool_cls = tools['<TOOL_NAME>']
47
+
48
+ # Print the python definition of the tool.
49
+ print(tool_cls.python_definition())
50
+
51
+ async with client.session() as session:
52
+ return await tool_cls(x=1, y=2).acall(session)
53
+ ```
54
+ """
55
+
56
+ def _on_bound(self):
57
+ super()._on_bound()
58
+ self._tools = None
59
+
60
+ def list_tools(
61
+ self, refresh: bool = False
62
+ ) -> dict[str, Type[mcp_tool.McpTool]]:
63
+ """Lists all MCP tools."""
64
+ if self._tools is None or refresh:
65
+ with self.session() as session:
66
+ self._tools = session.list_tools()
67
+ return self._tools
68
+
69
+ @abc.abstractmethod
70
+ def session(self) -> mcp_session.McpSession:
71
+ """Creates a MCP session."""
72
+
73
+ @classmethod
74
+ def from_command(cls, command: str, args: list[str]) -> 'McpClient':
75
+ """Creates a MCP client from a tool."""
76
+ return _StdioMcpClient(command=command, args=args)
77
+
78
+ @classmethod
79
+ def from_url(
80
+ cls,
81
+ url: str,
82
+ headers: dict[str, str] | None = None
83
+ ) -> 'McpClient':
84
+ """Creates a MCP client from a URL."""
85
+ return _HttpMcpClient(url=url, headers=headers or {})
86
+
87
+ @classmethod
88
+ def from_fastmcp(cls, fastmcp: fastmcp_lib.FastMCP) -> 'McpClient':
89
+ """Creates a MCP client from a MCP server."""
90
+ return _InMemoryFastMcpClient(fastmcp=fastmcp)
91
+
92
+
93
+ class _StdioMcpClient(McpClient):
94
+ """Stdio-based MCP client."""
95
+
96
+ command: Annotated[str, 'Command to execute.']
97
+ args: Annotated[list[str], 'Arguments to pass to the command.']
98
+
99
+ def session(self) -> mcp_session.McpSession:
100
+ """Creates a MCP session."""
101
+ return mcp_session.McpSession.from_command(self.command, self.args)
102
+
103
+
104
+ class _HttpMcpClient(McpClient):
105
+ """Server-Sent Events (SSE)/Streamable HTTP-based MCP client."""
106
+
107
+ url: Annotated[str, 'URL to connect to.']
108
+ headers: Annotated[dict[str, str], 'Headers to send with the request.'] = {}
109
+
110
+ def session(self) -> mcp_session.McpSession:
111
+ """Creates a MCP session."""
112
+ return mcp_session.McpSession.from_url(self.url, self.headers)
113
+
114
+
115
+ class _InMemoryFastMcpClient(McpClient):
116
+ """In-memory MCP client."""
117
+
118
+ fastmcp: Annotated[fastmcp_lib.FastMCP, 'MCP server to connect to.']
119
+
120
+ def session(self) -> mcp_session.McpSession:
121
+ """Creates a MCP session."""
122
+ return mcp_session.McpSession.from_fastmcp(self.fastmcp)
@@ -0,0 +1,98 @@
1
+ # Copyright 2025 The Langfun Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Tests for MCP client."""
15
+
16
+ import inspect
17
+ import unittest
18
+ from langfun.core import async_support
19
+ from langfun.core import mcp as lf_mcp
20
+ from mcp.server import fastmcp as fastmcp_lib
21
+
22
+ mcp = fastmcp_lib.FastMCP(host='0.0.0.0', port=1234)
23
+
24
+
25
+ @mcp.tool()
26
+ async def add(a: int, b: int) -> int:
27
+ """Adds two integers and returns their sum.
28
+
29
+ Args:
30
+ a: The first integer.
31
+ b: The second integer.
32
+
33
+ Returns:
34
+ The sum of the two integers.
35
+ """
36
+ return a + b
37
+
38
+
39
+ class McpTest(unittest.TestCase):
40
+
41
+ def test_sync_usages(self):
42
+ client = lf_mcp.McpClient.from_fastmcp(mcp)
43
+ tools = client.list_tools()
44
+ self.assertEqual(len(tools), 1)
45
+ tool_cls = tools['add']
46
+ print(tool_cls.python_definition())
47
+ self.assertEqual(
48
+ tool_cls.python_definition(),
49
+ inspect.cleandoc(
50
+ '''
51
+ Add
52
+
53
+ ```python
54
+ class Add:
55
+ """Adds two integers and returns their sum.
56
+
57
+ Args:
58
+ a: The first integer.
59
+ b: The second integer.
60
+
61
+ Returns:
62
+ The sum of the two integers.
63
+ """
64
+ a: int
65
+ b: int
66
+ ```
67
+ '''
68
+ )
69
+ )
70
+ self.assertEqual(repr(tool_cls), '<tool-class \'Add\'>')
71
+ self.assertEqual(tool_cls.__name__, 'Add')
72
+ self.assertEqual(tool_cls.TOOL_NAME, 'add')
73
+ self.assertEqual(tool_cls(a=1, b=2).input_parameters(), {'a': 1, 'b': 2})
74
+ with client.session() as session:
75
+ self.assertEqual(
76
+ tool_cls(a=1, b=2)(session), 3
77
+ )
78
+
79
+ def test_async_usages(self):
80
+ async def _test():
81
+ client = lf_mcp.McpClient.from_fastmcp(mcp)
82
+ tools = client.list_tools()
83
+ self.assertEqual(len(tools), 1)
84
+ tool_cls = tools['add']
85
+ self.assertEqual(tool_cls.__name__, 'Add')
86
+ self.assertEqual(tool_cls.TOOL_NAME, 'add')
87
+ async with client.session() as session:
88
+ self.assertEqual(
89
+ (await tool_cls(a=1, b=2).acall(
90
+ session, returns_call_result=True))
91
+ .structuredContent['result'],
92
+ 3
93
+ )
94
+ async_support.invoke_sync(_test)
95
+
96
+
97
+ if __name__ == '__main__':
98
+ unittest.main()
@@ -0,0 +1,176 @@
1
+ # Copyright 2025 The Langfun Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """MCP session."""
15
+
16
+ import contextlib
17
+ from typing import Any, Type
18
+ import anyio
19
+ from langfun.core import async_support
20
+ from langfun.core.mcp import tool as mcp_tool
21
+ import mcp
22
+ from mcp.client import sse
23
+ from mcp.client import streamable_http
24
+ from mcp.server import fastmcp as fastmcp_lib
25
+ from mcp.shared import memory
26
+
27
+
28
+ class McpSession:
29
+ """Langfun's MCP session.
30
+
31
+ Compared to the standard mcp.ClientSession, Langfun's MCP session could be
32
+ used both synchronously and asynchronously.
33
+ """
34
+
35
+ def __init__(self, stream) -> None:
36
+ self._stream = stream
37
+ self._session = None
38
+ self._session_exit_stack = None
39
+ self._in_session = False
40
+
41
+ # For supporting sync context manager.
42
+ self._sync_context_manager_exit_stack = None
43
+
44
+ def __enter__(self) -> 'McpSession':
45
+ exit_stack = contextlib.ExitStack()
46
+ exit_stack.enter_context(async_support.sync_context_manager(self))
47
+ self._sync_context_manager_exit_stack = exit_stack
48
+ return self
49
+
50
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
51
+ assert self._sync_context_manager_exit_stack is not None
52
+ self._sync_context_manager_exit_stack.close()
53
+
54
+ async def __aenter__(self) -> 'McpSession':
55
+ assert self._session_exit_stack is None, 'Session cannot be re-entered.'
56
+
57
+ self._session_exit_stack = contextlib.AsyncExitStack()
58
+ stream_output = await self._session_exit_stack.enter_async_context(
59
+ self._stream
60
+ )
61
+ assert isinstance(stream_output, tuple) and len(stream_output) in (2, 3)
62
+ read, write = stream_output[:2]
63
+ self._session = mcp.ClientSession(read, write)
64
+ await self._session_exit_stack.enter_async_context(self._session)
65
+ await self._session.initialize()
66
+ return self
67
+
68
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
69
+ del exc_type, exc_val, exc_tb
70
+ if self._session is None:
71
+ return
72
+ assert self._session_exit_stack is not None
73
+ await self._session_exit_stack.aclose()
74
+ self._session = None
75
+
76
+ def list_tools(self) -> dict[str, Type[mcp_tool.McpTool]]:
77
+ """Lists all MCP tools synchronously."""
78
+ return async_support.invoke_sync(self.alist_tools)
79
+
80
+ async def alist_tools(self) -> dict[str, Type[mcp_tool.McpTool]]:
81
+ """Lists all MCP tools asynchronously."""
82
+ assert self._session is not None, 'MCP session is not entered.'
83
+ return {
84
+ t.name: mcp_tool.McpTool.make_class(t)
85
+ for t in (await self._session.list_tools()).tools
86
+ }
87
+
88
+ def call_tool(
89
+ self,
90
+ tool: mcp_tool.McpTool,
91
+ *,
92
+ returns_call_result: bool = False
93
+ ) -> Any:
94
+ """Calls a MCP tool synchronously."""
95
+ return async_support.invoke_sync(
96
+ self.acall_tool,
97
+ tool,
98
+ returns_call_result=returns_call_result
99
+ )
100
+
101
+ async def acall_tool(
102
+ self,
103
+ tool: mcp_tool.McpTool,
104
+ *,
105
+ returns_call_result: bool = False
106
+ ) -> Any:
107
+ """Calls a MCP tool asynchronously."""
108
+ assert self._session is not None, 'MCP session is not entered.'
109
+ tool_call_result = await self._session.call_tool(
110
+ tool.TOOL_NAME, tool.input_parameters()
111
+ )
112
+ if returns_call_result:
113
+ return tool_call_result
114
+ if (
115
+ tool_call_result.structuredContent
116
+ and 'result' in tool_call_result.structuredContent
117
+ ):
118
+ return tool_call_result.structuredContent['result']
119
+ return tool_call_result.content
120
+
121
+ @classmethod
122
+ def from_command(
123
+ cls,
124
+ command: str,
125
+ args: list[str] | None = None
126
+ ) -> 'McpSession':
127
+ """Creates a MCP session from a command."""
128
+ return cls(
129
+ mcp.stdio_client(
130
+ mcp.StdioServerParameters(command=command, args=args or [])
131
+ )
132
+ )
133
+
134
+ @classmethod
135
+ def from_url(
136
+ cls,
137
+ url: str,
138
+ headers: dict[str, str] | None = None
139
+ ) -> 'McpSession':
140
+ """Creates a MCP session from a URL."""
141
+ transport = url.removesuffix('/').split('/')[-1].lower()
142
+ if transport == 'mcp':
143
+ return cls(streamable_http.streamablehttp_client(url, headers or {}))
144
+ elif transport == 'sse':
145
+ return cls(sse.sse_client(url, headers or {}))
146
+ else:
147
+ raise ValueError(f'Unsupported transport: {transport}')
148
+
149
+ @classmethod
150
+ def from_fastmcp(
151
+ cls,
152
+ fastmcp: fastmcp_lib.FastMCP
153
+ ):
154
+ return cls(_client_streams_from_fastmcp(fastmcp))
155
+
156
+
157
+ @contextlib.asynccontextmanager
158
+ async def _client_streams_from_fastmcp(fastmcp: fastmcp_lib.FastMCP):
159
+ """Creates client streams from a MCP server."""
160
+ server = fastmcp._mcp_server # pylint: disable=protected-access
161
+ async with memory.create_client_server_memory_streams(
162
+ ) as (client_streams, server_streams):
163
+ client_read, client_write = client_streams
164
+ server_read, server_write = server_streams
165
+
166
+ # Create a cancel scope for the server task
167
+ async with anyio.create_task_group() as tg:
168
+ tg.start_soon(
169
+ lambda: server.run(
170
+ server_read,
171
+ server_write,
172
+ server.create_initialization_options(),
173
+ raise_exceptions=True,
174
+ )
175
+ )
176
+ yield client_read, client_write
@@ -0,0 +1,33 @@
1
+ # Copyright 2025 The Langfun Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """A simple MCP client for testing."""
15
+
16
+ from absl import app
17
+ from absl import flags
18
+ from langfun.core import mcp
19
+
20
+
21
+ _URL = flags.DEFINE_string(
22
+ 'url',
23
+ 'http://localhost:8000/mcp',
24
+ 'URL of the MCP server.',
25
+ )
26
+
27
+
28
+ def main(_):
29
+ print(mcp.McpClient.from_url(url=_URL.value).list_tools())
30
+
31
+
32
+ if __name__ == '__main__':
33
+ app.run(main)
@@ -0,0 +1,33 @@
1
+ # Copyright 2025 The Langfun Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Simple MCP server for testing."""
15
+
16
+ from absl import app as absl_app
17
+ from mcp.server import fastmcp as fastmcp_lib
18
+
19
+ mcp = fastmcp_lib.FastMCP(host='0.0.0.0', port=8000)
20
+
21
+
22
+ @mcp.tool()
23
+ async def add(a: int, b: int) -> int:
24
+ """Adds two integers and returns their sum."""
25
+ return a + b
26
+
27
+
28
+ def main(_):
29
+ mcp.run(transport='streamable-http', mount_path='/mcp')
30
+
31
+
32
+ if __name__ == '__main__':
33
+ absl_app.run(main)
@@ -0,0 +1,125 @@
1
+ # Copyright 2025 The Langfun Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """MCP tool."""
15
+
16
+ from typing import Annotated, Any, ClassVar
17
+
18
+ from langfun.core.structured import schema as lf_schema
19
+ import mcp
20
+ import pyglove as pg
21
+
22
+
23
+ class _McpToolMeta(pg.symbolic.ObjectMeta):
24
+
25
+ def __repr__(self) -> str:
26
+ return f'<tool-class \'{self.__name__}\'>'
27
+
28
+
29
+ class McpTool(pg.Object, metaclass=_McpToolMeta):
30
+ """Base class for MCP tools."""
31
+
32
+ TOOL_NAME: Annotated[
33
+ ClassVar[str],
34
+ 'Tool name.'
35
+ ]
36
+
37
+ @classmethod
38
+ def python_definition(cls, markdown: bool = True) -> str:
39
+ """Returns the Python definition of the tool."""
40
+ return lf_schema.Schema.from_value(cls).schema_str(
41
+ protocol='python', markdown=markdown
42
+ )
43
+
44
+ def __call__(
45
+ self,
46
+ session,
47
+ *,
48
+ returns_call_result: bool = False) -> Any:
49
+ """Calls a MCP tool synchronously.
50
+
51
+ Args:
52
+ session: A MCP session.
53
+ returns_call_result: If True, returns the call result. Otherwise returns
54
+ the result from structured content, or return content.
55
+
56
+ Returns:
57
+ The call result, or the result from structured content, or content.
58
+ """
59
+ return session.call_tool(
60
+ self,
61
+ returns_call_result=returns_call_result
62
+ )
63
+
64
+ async def acall(
65
+ self,
66
+ session,
67
+ *,
68
+ returns_call_result: bool = False) -> Any:
69
+ """Calls a MCP tool asynchronously."""
70
+ return await session.acall_tool(self, returns_call_result=returns_call_result)
71
+
72
+ def input_parameters(self) -> dict[str, Any]:
73
+ """Returns the input parameters of the tool."""
74
+ json = self.to_json()
75
+ def _transform(path: pg.KeyPath, x: Any) -> Any:
76
+ del path
77
+ if isinstance(x, dict):
78
+ x.pop(pg.JSONConvertible.TYPE_NAME_KEY, None)
79
+ return x
80
+ return pg.utils.transform(json, _transform)
81
+
82
+ @classmethod
83
+ def make_class(cls, tool_definition: mcp.Tool) -> type['McpTool']:
84
+ """Makes a MCP tool class from tool definition."""
85
+
86
+ class _McpTool(cls):
87
+ auto_schema = False
88
+
89
+ tool_cls = _McpTool
90
+ tool_cls.TOOL_NAME = tool_definition.name
91
+ tool_cls.__name__ = _snake_to_camel(tool_definition.name)
92
+ tool_cls.__doc__ = tool_definition.description
93
+ schema = pg.Schema.from_json_schema(
94
+ tool_definition.inputSchema, class_fn=McpToolInput.make_class
95
+ )
96
+ tool_cls.apply_schema(schema)
97
+ return tool_cls
98
+
99
+
100
+ class _McpToolInputMeta(pg.symbolic.ObjectMeta):
101
+
102
+ def __repr__(self) -> str:
103
+ return f'<input-class \'{self.__name__}\'>'
104
+
105
+
106
+ class McpToolInput(pg.Object, metaclass=_McpToolInputMeta):
107
+ """Base class for MCP tool inputs."""
108
+
109
+ @classmethod
110
+ def make_class(cls, name: str, schema: pg.Schema):
111
+ """Converts a schema to an input class."""
112
+
113
+ class _McpToolInput(cls):
114
+ pass
115
+
116
+ input_cls = _McpToolInput
117
+ input_cls.__name__ = _snake_to_camel(name)
118
+ input_cls.__doc__ = schema.description
119
+ input_cls.apply_schema(schema)
120
+ return input_cls
121
+
122
+
123
+ def _snake_to_camel(name: str) -> str:
124
+ """Converts a snake_case name to a CamelCase name."""
125
+ return ''.join(x.capitalize() for x in name.split('_'))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langfun
3
- Version: 0.1.2.dev202510160805
3
+ Version: 0.1.2.dev202510180803
4
4
  Summary: Langfun: Language as Functions.
5
5
  Home-page: https://github.com/google/langfun
6
6
  Author: Langfun Authors
@@ -21,14 +21,18 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
21
  Classifier: Topic :: Software Development :: Libraries
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
+ Requires-Dist: anyio>=4.7.0
24
25
  Requires-Dist: jinja2>=3.1.2
26
+ Requires-Dist: mcp>=1.17.0
25
27
  Requires-Dist: puremagic>=1.20
26
- Requires-Dist: pyglove>=0.4.5.dev202507140812
28
+ Requires-Dist: pyglove>=0.5.0.dev202510170226
27
29
  Requires-Dist: requests>=2.31.0
28
30
  Provides-Extra: all
31
+ Requires-Dist: anyio>=4.7.0; extra == "all"
29
32
  Requires-Dist: jinja2>=3.1.2; extra == "all"
33
+ Requires-Dist: mcp>=1.17.0; extra == "all"
30
34
  Requires-Dist: puremagic>=1.20; extra == "all"
31
- Requires-Dist: pyglove>=0.4.5.dev202507140812; extra == "all"
35
+ Requires-Dist: pyglove>=0.5.0.dev202510170226; extra == "all"
32
36
  Requires-Dist: requests>=2.31.0; extra == "all"
33
37
  Requires-Dist: google-auth>=2.16.0; extra == "all"
34
38
  Requires-Dist: pillow>=10.0.0; extra == "all"
@@ -1,4 +1,4 @@
1
- langfun/__init__.py,sha256=krEJ1lyDkNARsacY6nBQpD3bQrFi4fifD-FwpwPbFPM,2635
1
+ langfun/__init__.py,sha256=Kx-9_ODBpqqXb3cY5oSS-Sr-ZfuyM59W511ca9VYdRY,2663
2
2
  langfun/assistant/capabilities/gui/__init__.py,sha256=7-fWINsczHkEAT1hS4vVHnTW3JC_4PZW0E4HfjClOD8,1290
3
3
  langfun/assistant/capabilities/gui/bounding_box_parser.py,sha256=wJSEJvt2WFH0o9C3z9uW_vK0qRAJWbkgX5gBAJU7w6k,5832
4
4
  langfun/assistant/capabilities/gui/bounding_box_parser_test.py,sha256=9yBvt7fXT_BFVcOXUSm4K0mLdKRYwk2Y9zaCpYYgBnQ,10934
@@ -6,13 +6,13 @@ langfun/assistant/capabilities/gui/drawing.py,sha256=8wgol61P7HovLg5EaevRmDPXTFu
6
6
  langfun/assistant/capabilities/gui/drawing_test.py,sha256=d6LQ1ctG78YRi58UVBdndwyEqTC_ITdk191oA3tASxM,3421
7
7
  langfun/assistant/capabilities/gui/location.py,sha256=QYJlY5kUNEwiZFiPYRyFAO7bD2ez4jL5hYn1_AK1ulc,8643
8
8
  langfun/assistant/capabilities/gui/location_test.py,sha256=pQUOH1sKuAwjTTYKu615RUnecc_lNrddfvkyxf9297w,9051
9
- langfun/core/__init__.py,sha256=lZQNVBzQa5d6kQFHRwRg9zZqPaEC_-PwAV-73k4fzR4,4854
10
- langfun/core/async_support.py,sha256=Mzqze88WqWBRRcBVJEQ4H1jRgLwUP43acI0NZ7unZ7M,1022
11
- langfun/core/async_support_test.py,sha256=lZ4rZ-kWlc94lezTajOVo2OQbxWiHfF6KhKOfMGzELQ,1094
9
+ langfun/core/__init__.py,sha256=GC4amBXybjSfYVrEQSLFb_KBhH75uD95GKkHNiJfwkY,5011
10
+ langfun/core/async_support.py,sha256=WF4sflm0Q-UHJ8lPtlEo9hSwqXqm1kfaAYVPTyVP3n0,3062
11
+ langfun/core/async_support_test.py,sha256=fMz1ulGrfUCuHp7RtYktuBwpbRN3kCBKnB4LFvaXSAA,1754
12
12
  langfun/core/component.py,sha256=g1kQM0bryYYYWVDrSMnHfc74wIBbpfe5_B3s-UIP5GE,3028
13
13
  langfun/core/component_test.py,sha256=0CxTgjAud3aj8wBauFhG2FHDqrxCTl4OI4gzQTad-40,9254
14
14
  langfun/core/concurrent.py,sha256=zY-pXqlGqss_GI20tM1gXvyW8QepVPUuFNmutcIdhbI,32760
15
- langfun/core/concurrent_test.py,sha256=zrkDid2oHSXJYGPhrQzA_6Af6oHAn9UrnYGZmN00ies,17693
15
+ langfun/core/concurrent_test.py,sha256=KzXOlfR3i_-s_GKBLYrO5-ETCvHoFbFY2o9FEeOeXq4,17818
16
16
  langfun/core/console.py,sha256=cLQEf84aDxItA9fStJV22xJch0TqFLNf9hLqwJ0RHmU,2652
17
17
  langfun/core/console_test.py,sha256=pBOcuNMJdVELywvroptfcRtJMsegMm3wSlHAL2TdxVk,1679
18
18
  langfun/core/langfunc.py,sha256=G50YgoVZ0y1GFw2ev41MlOqr6qa8YakbvNC0h_E0PiA,11140
@@ -85,7 +85,7 @@ langfun/core/eval/v2/metrics_test.py,sha256=LibZXvWEJDVRY-Mza_bQT-SbmbXCHUnFhL7Z
85
85
  langfun/core/eval/v2/progress.py,sha256=azZgssQgNdv3IgjKEaQBuGI5ucFDNbdi02P4z_nQ8GE,10292
86
86
  langfun/core/eval/v2/progress_test.py,sha256=YU7VHzmy5knPZwj9vpBN3rQQH2tukj9eKHkuBCI62h8,2540
87
87
  langfun/core/eval/v2/progress_tracking.py,sha256=zNhNPGlnJnHELEfFpbTMCSXFn8d1IJ57OOYkfFaBFfM,6097
88
- langfun/core/eval/v2/progress_tracking_test.py,sha256=0d13LQyUKy1_bkscN0-vcBcQ36HNp89kgJ_N0jl2URM,2339
88
+ langfun/core/eval/v2/progress_tracking_test.py,sha256=MC7hD-KfxqSIwKK_BN7oGx7HA8O3_fY-Y3cYYAhZzxE,2343
89
89
  langfun/core/eval/v2/reporting.py,sha256=yUIPCAMnp7InIzpv1DDWrcLO-75iiOUTpscj7smkfrA,8335
90
90
  langfun/core/eval/v2/reporting_test.py,sha256=CMK-vwho8cNRJwlbkCqm_v5fykE7Y3V6SaIOCY0CDyA,5671
91
91
  langfun/core/eval/v2/runners.py,sha256=bEniZDNu44AQgvqpwLsvBU4V_7WltAe-NPhYgIsLj1E,16848
@@ -121,6 +121,13 @@ langfun/core/llms/cache/__init__.py,sha256=QAo3InUMDM_YpteNnVCSejI4zOsnjSMWKJKzk
121
121
  langfun/core/llms/cache/base.py,sha256=rt3zwmyw0y9jsSGW-ZbV1vAfLxQ7_3AVk0l2EySlse4,3918
122
122
  langfun/core/llms/cache/in_memory.py,sha256=i58oiQL28RDsq37dwqgVpC2mBETJjIEFS20yHiV5MKU,5185
123
123
  langfun/core/llms/cache/in_memory_test.py,sha256=V2UPeu5co5vUwSkjekCH1B1iLm9qQKPaacvP6VW3GTg,10388
124
+ langfun/core/mcp/__init__.py,sha256=cyVP_YTjOmbjhYg8BE7-RnE4Txt8wDukYC0anhHKpuo,286
125
+ langfun/core/mcp/client.py,sha256=NBo4W2fdvYyywlUHynAbyh7LNohILccTkCiisj-UEEg,3642
126
+ langfun/core/mcp/client_test.py,sha256=Vjbh7bXM8xXMMe_QOnFJ4rE0wITovAZbwk7Nu-cqf8g,2766
127
+ langfun/core/mcp/session.py,sha256=dyuNXpLvM0TgLoUmWEwy5XCRf-IK7H_BbcNI2oFa_ek,5626
128
+ langfun/core/mcp/tool.py,sha256=eap46OK4lrw5G5uCwPi4kUMNdaN1Bgy0cUi0bdLJI5Q,3607
129
+ langfun/core/mcp/testing/simple_mcp_client.py,sha256=U5pUXa0SnB2-DdRI2vPhrdVUv14dX_OeS4mPLFpllMc,924
130
+ langfun/core/mcp/testing/simple_mcp_server.py,sha256=dw4t6ERWMdmi6kDE38RU5oYu5MQbEX-GJ6CMxGcV-WE,994
124
131
  langfun/core/memories/__init__.py,sha256=HpghfZ-w1NQqzJXBx8Lz0daRhB2rcy2r9Xm491SBhC4,773
125
132
  langfun/core/memories/conversation_history.py,sha256=KR78PurXTSeqsRK9QG9xM7-f245rs20EvWO7Mm2n2Vw,1827
126
133
  langfun/core/memories/conversation_history_test.py,sha256=2kzAq2pUrbR01Z9jhxviIao52JK4JVjr5iGP8pwGxlU,2156
@@ -181,8 +188,8 @@ langfun/env/event_handlers/event_logger.py,sha256=3dbPjBe53dBgntYHlyLlj_77hVecPS
181
188
  langfun/env/event_handlers/event_logger_test.py,sha256=PGof3rPllNnyzs3Yp8kaOHLeTkVrzUgCJwlODTrVRKI,9111
182
189
  langfun/env/event_handlers/metric_writer.py,sha256=NgJKsd6xWOtEd0IjYi7coGEaqGYkkPcDjXN9CQ3vxPU,18043
183
190
  langfun/env/event_handlers/metric_writer_test.py,sha256=flRqK10wonhJk4idGD_8jjEjrfjgH0R-qcu-7Bj1G5s,5335
184
- langfun-0.1.2.dev202510160805.dist-info/licenses/LICENSE,sha256=WNHhf_5RCaeuKWyq_K39vmp9F28LxKsB4SpomwSZ2L0,11357
185
- langfun-0.1.2.dev202510160805.dist-info/METADATA,sha256=au899IUDN58JgGfkb7QaynFP9i-qZruve58dFnL8b4g,7380
186
- langfun-0.1.2.dev202510160805.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
187
- langfun-0.1.2.dev202510160805.dist-info/top_level.txt,sha256=RhlEkHxs1qtzmmtWSwYoLVJAc1YrbPtxQ52uh8Z9VvY,8
188
- langfun-0.1.2.dev202510160805.dist-info/RECORD,,
191
+ langfun-0.1.2.dev202510180803.dist-info/licenses/LICENSE,sha256=WNHhf_5RCaeuKWyq_K39vmp9F28LxKsB4SpomwSZ2L0,11357
192
+ langfun-0.1.2.dev202510180803.dist-info/METADATA,sha256=TKDSGp-jEDTFAdPMimMw8gFja3rQ3LQ4YHqko8-Ga7k,7522
193
+ langfun-0.1.2.dev202510180803.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
194
+ langfun-0.1.2.dev202510180803.dist-info/top_level.txt,sha256=RhlEkHxs1qtzmmtWSwYoLVJAc1YrbPtxQ52uh8Z9VvY,8
195
+ langfun-0.1.2.dev202510180803.dist-info/RECORD,,