Repository: openlawlibrary/pygls
Pull request #527 (2025-05-17 19:00:47)

Use type annotations when invoking `@server.command(...)` handlers
## Description (e.g. "Related to ...", etc.)

I *think* this is the last major change I wanted to add to v2! :sweat_smile: 

With this PR *pygls* will now use a handler's type annotations (if available) to automatically convert json arguments into well-defined objects. If the supplied arguments are not compatible with the annotations, an error will automatically be raised.

These type annotations are **not mandatory** and arguments will be given the raw value when an annotation is missing. However, I will not be surprised if there are subtle differences in how handlers are called when compared to the existing behaviour. 

<details>
<summary>Before</summary>

```python

@server.command("codeLens.evaluateSum")
def evaluate_sum(ls: LanguageServer, args):
    logging.info("arguments: %s", args)

    arguments = args[0]
    document = ls.workspace.get_text_document(arguments["uri"])
    line = document.lines[arguments["line"]]
```

</details>

<details>
<summary>After</summary>

```python

@attrs.define
class EvaluateSumArgs:
    """Represents the arguments to pass to the ``codeLens.evaluateSum`` command"""
    uri: str
    """The uri of the document to edit"""

    left: int
    """The left argument to ``+``"""

    right: int
    """The right argument to ``+``"""

    line: int
    """The line number to edit"""

@server.command("codeLens.evaluateSum")
def evaluate_sum(ls: LanguageServer, args: EvaluateSumArgs):
    logging.info("arguments: %s", args)

    document = ls.workspace.get_text_document(args.uri)
    line = document.lines[args.line]
```

</details>

See the new `examples/servers/commands.py` server for more examples.

Python function signatures can be made to be almost arbitrarily complex - especially when you throw in positional/keyword only arguments! So I would not expect every possible function to work just yet that said, have I missed any obvious use cases?

## Code review checklist (for code reviewer to complete)

- [x] Pull request represents a single change (i.e. not fixing disparate/unrelated things in a single PR)
- [x] Title summarizes what is changing
- [x] Commit messages are meaningful (see [this][commit messages] for details)
- [x] Tests have been included and/or updated, as appropriate
- [x] Docstrings have been included and/or updated, as appropriate
- [x] Standalone docs have been updated accordingly

## Automated linters

You can run the lints that are run on CI locally with:
```sh
poetry install --all-extras --with dev
poetry run poe lint
```

[commit messages]: https://conventionalcommits.org/


tombh: Great work again! It's so elegant 🥹

And all the docs, examples and tests, wonderful as ever ❤️
alcarney: Thanks! :) 

Patch:
diff --git a/docs/source/pygls/howto/migrate-to-v2.rst b/docs/source/pygls/howto/migrate-to-v2.rst
index c1bae48..10826bf 100644
--- a/docs/source/pygls/howto/migrate-to-v2.rst
+++ b/docs/source/pygls/howto/migrate-to-v2.rst
@@ -52,6 +52,106 @@ The following methods and functions have been deprecated for some time and have
 ``Worspace.update_document``                        ``Workspace.update_text_document``
 ==================================================  ==============
 
+Server commands can now use type annotations
+--------------------------------------------
+
+*pygls* will now inspect a function's type annotations when handling ``workspace/executeCommand`` requests, automatically converting JSON values to ``attrs`` class instances, or responding with an error if appropriate.
+
+It is **not mandatory** to start using type annotations in your command definitions, but you may notice a difference in how *pygls* calls your server command methods.
+
+**Before**
+
+::
+
+   @server.command("codeLens.evaluateSum")
+   def evaluate_sum(ls: LanguageServer, args):
+       logging.info("arguments: %s", args)
+
+       arguments = args[0]
+       document = ls.workspace.get_text_document(arguments["uri"])
+       line = document.lines[arguments["line"]]
+
+       # Compute the edit that will update the document with the result.
+       answer = arguments["left"] + arguments["right"]
+       edit = types.TextDocumentEdit(
+           text_document=types.OptionalVersionedTextDocumentIdentifier(
+               uri=arguments["uri"],
+               version=document.version,
+           ),
+           edits=[
+               types.TextEdit(
+                   new_text=f"{line.strip()} {answer}\n",
+                   range=types.Range(
+                       start=types.Position(line=arguments["line"], character=0),
+                       end=types.Position(line=arguments["line"] + 1, character=0),
+                   ),
+               )
+           ],
+       )
+
+       # Apply the edit.
+       ls.workspace_apply_edit(
+           types.ApplyWorkspaceEditParams(
+               edit=types.WorkspaceEdit(document_changes=[edit]),
+           ),
+       )
+
+**After**
+
+::
+
+    @attrs.define
+    class EvaluateSumArgs:
+        """Represents the arguments to pass to the ``codeLens.evaluateSum`` command"""
+
+        uri: str
+        """The uri of the document to edit"""
+
+        left: int
+        """The left argument to ``+``"""
+
+        right: int
+        """The right argument to ``+``"""
+
+        line: int
+        """The line number to edit"""
+
+
+    @server.command("codeLens.evaluateSum")
+    def evaluate_sum(ls: LanguageServer, args: EvaluateSumArgs):
+        logging.info("arguments: %s", args)
+
+        document = ls.workspace.get_text_document(args.uri)
+        line = document.lines[args.line]
+
+        # Compute the edit that will update the document with the result.
+        answer = args.left + args.right
+        edit = types.TextDocumentEdit(
+            text_document=types.OptionalVersionedTextDocumentIdentifier(
+                uri=args.uri,
+                version=document.version,
+            ),
+            edits=[
+                types.TextEdit(
+                    new_text=f"{line.strip()} {answer}\n",
+                    range=types.Range(
+                        start=types.Position(line=args.line, character=0),
+                        end=types.Position(line=args.line + 1, character=0),
+                    ),
+                )
+            ],
+        )
+
+        # Apply the edit.
+        ls.workspace_apply_edit(
+            types.ApplyWorkspaceEditParams(
+                edit=types.WorkspaceEdit(document_changes=[edit]),
+            ),
+        )
+
+
+
+
 Renamed ``LanguageServer`` Methods
 ----------------------------------
 
diff --git a/examples/servers/code_lens.py b/examples/servers/code_lens.py
index f7b41ba..6406eaf 100644
--- a/examples/servers/code_lens.py
+++ b/examples/servers/code_lens.py
@@ -32,6 +32,7 @@ computation until it is actually necessary.
 import logging
 import re
 
+import attrs
 from lsprotocol import types
 
 from pygls.cli import start_server
@@ -77,6 +78,23 @@ def code_lens(params: types.CodeLensParams):
     return items
 
 
+@attrs.define
+class EvaluateSumArgs:
+    """Represents the arguments to pass to the ``codeLens.evaluateSum`` command"""
+
+    uri: str
+    """The uri of the document to edit"""
+
+    left: int
+    """The left argument to ``+``"""
+
+    right: int
+    """The right argument to ``+``"""
+
+    line: int
+    """The line number to edit"""
+
+
 @server.feature(types.CODE_LENS_RESOLVE)
 def code_lens_resolve(ls: LanguageServer, item: types.CodeLens):
     """Resolve the ``command`` field of the given code lens.
@@ -90,7 +108,7 @@ def code_lens_resolve(ls: LanguageServer, item: types.CodeLens):
     right = item.data["right"]
     uri = item.data["uri"]
 
-    args = dict(
+    args = EvaluateSumArgs(
         uri=uri,
         left=left,
         right=right,
@@ -106,26 +124,25 @@ def code_lens_resolve(ls: LanguageServer, item: types.CodeLens):
 
 
 @server.command("codeLens.evaluateSum")
-def evaluate_sum(ls: LanguageServer, args):
+def evaluate_sum(ls: LanguageServer, args: EvaluateSumArgs):
     logging.info("arguments: %s", args)
 
-    arguments = args[0]
-    document = ls.workspace.get_text_document(arguments["uri"])
-    line = document.lines[arguments["line"]]
+    document = ls.workspace.get_text_document(args.uri)
+    line = document.lines[args.line]
 
     # Compute the edit that will update the document with the result.
-    answer = arguments["left"] + arguments["right"]
+    answer = args.left + args.right
     edit = types.TextDocumentEdit(
         text_document=types.OptionalVersionedTextDocumentIdentifier(
-            uri=arguments["uri"],
+            uri=args.uri,
             version=document.version,
         ),
         edits=[
             types.TextEdit(
                 new_text=f"{line.strip()} {answer}\n",
                 range=types.Range(
-                    start=types.Position(line=arguments["line"], character=0),
-                    end=types.Position(line=arguments["line"] + 1, character=0),
+                    start=types.Position(line=args.line, character=0),
+                    end=types.Position(line=args.line + 1, character=0),
                 ),
             )
         ],
diff --git a/examples/servers/commands.py b/examples/servers/commands.py
new file mode 100644
index 0000000..aa54111
--- /dev/null
+++ b/examples/servers/commands.py
@@ -0,0 +1,153 @@
+############################################################################
+# Copyright(c) Open Law Library. All rights reserved.                      #
+# See ThirdPartyNotices.txt in the project root for additional notices.    #
+#                                                                          #
+# Licensed under the Apache License, Version 2.0 (the "License")           #
+# you may not use this file except in compliance with the License.         #
+# You may obtain a copy of the License at                                  #
+#                                                                          #
+#     http: // www.apache.org/licenses/LICENSE-2.0                         #
+#                                                                          #
+# Unless required by applicable law or agreed to in writing, software      #
+# distributed under the License is distributed on an "AS IS" BASIS,        #
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
+# See the License for the specific language governing permissions and      #
+# limitations under the License.                                           #
+############################################################################
+"""This demonstrates the different ways in which server commands can be registered.
+"""
+from __future__ import annotations
+
+import logging
+import math
+
+import attrs
+from lsprotocol import types
+
+from pygls.cli import start_server
+from pygls.lsp.server import LanguageServer
+
+server = LanguageServer("commands-server", "v1")
+
+
+@server.command("random")
+def random():
+    """Accepts no arguments, returns a random number"""
+    return 4  # https://xkcd.com/221/
+
+
+@server.command("random.wrapped")
+def random_wrapped(ls):
+    """Accepts no arguments (but uses an injected server instance).
+    Returns a random number"""
+    ls.window_log_message(
+        types.LogMessageParams(type=types.MessageType.Info, message="running...")
+    )
+    return 4
+
+
+@server.command("calculate.sum")
+def calculate_sum(*args):
+    """Using *args to accept any number of arguments"""
+    logging.info("arguments: %r", args)
+    return sum(args)
+
+
+@server.command("calculate.sum.wrapped")
+def calculate_sum_wrapped(s: LanguageServer, *args):
+    """Using *args to accept any number of arguments"""
+    s.window_log_message(
+        types.LogMessageParams(type=types.MessageType.Info, message=f"{args=}")
+    )
+    return calculate_sum(*args)
+
+
+@server.command("calculate.pow")
+def calculate_pow(x: float, n):
+    """"""
+    logging.info("x: %r, n: %r", x, n)
+    return x**n
+
+
+@server.command("calculate.pow.wrapped")
+def calculate_pow_wrapped(s: LanguageServer, x, n: int):
+    """Using *args to accept any number of arguments"""
+    s.window_log_message(
+        types.LogMessageParams(type=types.MessageType.Info, message=f"{x=}, {n=}")
+    )
+    return calculate_pow(x, n)
+
+
+@server.command("calculate.div")
+def calculate_div(x: float, n):
+    """"""
+    logging.info("x: %r, n: %r", x, n)
+    return x / n
+
+
+@server.command("calculate.div.wrapped")
+def calculate_div_wrapped(s: LanguageServer, x, n: float):
+    """Using *args to accept any number of arguments"""
+    s.window_log_message(
+        types.LogMessageParams(type=types.MessageType.Info, message=f"{x=}, {n=}")
+    )
+    return calculate_div(x, n)
+
+
+@attrs.define
+class Triangle:
+    """A triangle."""
+
+    a: float
+    """Length of side a"""
+
+    b: float
+    """Length of side b"""
+
+    c: float = attrs.field(default=0)
+    """Length of side c - the hypotenuse"""
+
+
+@server.command("calculate.triangle.hypotenuse")
+def calculate_triangle_hypotenuse(triangle: Triangle):
+    """When type annotations are given, pygls will automatically convert compatible
+    values to their attrs-based representation"""
+    logging.info("triangle: %r", triangle)
+
+    triangle.c = math.sqrt(triangle.a**2 + triangle.b**2)
+    return triangle
+
+
+@server.command("calculate.triangle.hypotenuse.wrapped")
+def calculate_triangle_hypotenuse_wrapped(ls: LanguageServer, triangle: Triangle):
+    """When type annotations are given, pygls will automatically convert compatible
+    values to their attrs-based representation"""
+    ls.window_log_message(
+        types.LogMessageParams(type=types.MessageType.Info, message=f"{triangle=}")
+    )
+    return calculate_triangle_hypotenuse(triangle)
+
+
+@server.command("calculate.untyped.hypotenuse")
+def calculate_untyped_hypotenuse(tri):
+    """Calculate the hypotenuse of a right-angled triangle"""
+    logging.info("tri: %r", tri)
+
+    a = tri["a"]
+    b = tri["b"]
+    c = math.sqrt(a**2 + b**2)
+    return dict(a=a, b=b, c=c)
+
+
+@server.command("calculate.untyped.hypotenuse.wrapped")
+def calculate_untyped_hypotenuse_wrapped(ls: LanguageServer, tri):
+    """Calculate the hypotenuse of a right-angled triangle"""
+    ls.window_log_message(
+        types.LogMessageParams(type=types.MessageType.Info, message=f"{tri=}")
+    )
+    return calculate_untyped_hypotenuse(tri)
+
+
+if __name__ == "__main__":
+    logging.basicConfig(level=logging.INFO, format="%(message)s")
+    start_server(server)
diff --git a/pygls/exceptions.py b/pygls/exceptions.py
index 0071f06..e1d9b2c 100644
--- a/pygls/exceptions.py
+++ b/pygls/exceptions.py
@@ -17,6 +17,7 @@
 # limitations under the License.                                           #
 ############################################################################
 import traceback
+from typing import Any
 from typing import Set
 from typing import Type
 from lsprotocol.types import ResponseError
@@ -25,6 +26,8 @@ from lsprotocol.types import ResponseError
 class JsonRpcException(Exception):
     """A class used as a base class for json rpc exceptions."""
 
+    MESSAGE = ""
+
     def __init__(self, message=None, code=None, data=None):
         message = message or getattr(self.__class__, "MESSAGE")
         super().__init__(message)
@@ -52,6 +55,15 @@ class JsonRpcException(Exception):
 
         return JsonRpcException(code=error.code, message=error.message, data=error.data)
 
+    @classmethod
+    def of(cls, exc: Any):
+        """Default ``of`` implementation that raises a ``JsonRpcException`` derived from
+        the given exception
+        """
+        return cls(
+            message=f"{cls.MESSAGE}: {exc}",
+        )
+
     @classmethod
     def supports_code(cls, code):
         # Defaults to UnknownErrorCode
@@ -91,7 +103,7 @@ class JsonRpcMethodNotFound(JsonRpcException):
     MESSAGE = "Method Not Found"
 
     @classmethod
-    def of(cls, method):
+    def of(cls, method: str):
         return cls(message=cls.MESSAGE + ": " + method)
 
 
diff --git a/pygls/protocol/json_rpc.py b/pygls/protocol/json_rpc.py
index 0e90096..3a48c60 100644
--- a/pygls/protocol/json_rpc.py
+++ b/pygls/protocol/json_rpc.py
@@ -273,9 +273,14 @@ class JsonRPCProtocol:
                         f'Request with id "{msg_id}" is canceled'
                     ).to_response_error(),
                 )
+        except JsonRpcException as exc:
+            logger.exception('Exception occurred for message "%s"', msg_id)
+            self._send_response(msg_id, error=exc.to_response_error())
+            self._server._report_server_error(exc, FeatureRequestError)
+
         except Exception:
             error = JsonRpcInternalError.of(sys.exc_info())
-            logger.exception('Exception occurred for message "%s": %s', msg_id, error)
+            logger.exception('Exception occurred for message "%s"', msg_id)
             self._send_response(msg_id, error=error.to_response_error())
             self._server._report_server_error(error, FeatureRequestError)
 
diff --git a/pygls/protocol/language_server.py b/pygls/protocol/language_server.py
index 6cc8bb6..d5b6ee3 100644
--- a/pygls/protocol/language_server.py
+++ b/pygls/protocol/language_server.py
@@ -28,6 +28,8 @@ from itertools import zip_longest
 from lsprotocol import types
 
 from pygls.capabilities import ServerCapabilitiesBuilder
+from pygls.constants import PARAM_LS
+from pygls.exceptions import JsonRpcInvalidParams
 from pygls.protocol.json_rpc import JsonRPCProtocol
 from pygls.uris import from_fs_path
 from pygls.workspace import Workspace
@@ -316,10 +318,19 @@ class LanguageServerProtocol(JsonRPCProtocol):
         self, params: types.ExecuteCommandParams
     ) -> Generator[Any, Any, Any]:
         """Executes commands with passed arguments and returns a value."""
-        cmd_handler = self.fm.commands[params.command]
 
-        # Call the user's command implementation
-        result = yield cmd_handler, (params.arguments,), None
+        if (handler := self.fm.commands.get(params.command, None)) is None:
+            raise JsonRpcInvalidParams.of(
+                ValueError(f"Command name {params.command!r} is not defined")
+            )
+
+        try:
+            args, kwargs = _prepare_command_arguments(handler, params, self._converter)
+        except Exception as exc:
+            raise JsonRpcInvalidParams.of(exc)
+
+        # Call the user's command handler.
+        result = yield handler, args, kwargs
         return result
 
     @lsp_method(types.WINDOW_WORK_DONE_PROGRESS_CANCEL)
@@ -337,3 +348,83 @@ class LanguageServerProtocol(JsonRPCProtocol):
             user_handler := self.fm.features.get(types.WINDOW_WORK_DONE_PROGRESS_CANCEL)
         ) is not None:
             yield user_handler, (params,), None
+
+
+def _prepare_command_arguments(
+    handler: Callable[..., Any],
+    params: types.ExecuteCommandParams,
+    converter: Converter,
+) -> tuple[tuple[Any, ...], dict[str, Any]]:
+    """Prepare the arguments to pass to the command handler."""
+
+    if params.arguments is None:
+        return tuple(), {}
+
+    # Import this here to not introduce an import cycle at the module level
+    from pygls.lsp.server import LanguageServer
+
+    param_vals = iter(params.arguments)
+    param_defs, annotations = _get_handler_params_annotations(handler)
+
+    args: list[Any] = []
+    kwargs: dict[str, Any] = {}
+
+    # param_defs is an OrderedDict so *in theory* at least we don't have to
+    # worry about argument order.
+    found_ls = False
+    for idx, (name, param) in enumerate(param_defs.items()):
+        ptype = annotations.get(name, None)
+
+        # We don't need to provide the injected server instance here.
+        # The @server.command decorator will have already handled it.
+        if idx == 0:
+            if name == PARAM_LS:
+                found_ls = True
+                continue
+
+            if (ptype is not None) and issubclass(ptype, LanguageServer):
+                found_ls = True
+                continue
+
+        if param.kind == inspect.Parameter.VAR_POSITIONAL:  # i.e. *args
+            # consume the remaining values
+            args.extend(param_vals)
+
+        else:
+            try:
+                value = converter.structure(next(param_vals), ptype)
+            except StopIteration as exc:
+                raise TypeError(
+                    f"Expected {len(param_defs) - found_ls} arguments, "
+                    f"got {len(params.arguments)}"
+                ) from exc
+
+            args.append(value)
+
+    # did we consume all the values?
+    if len(list(param_vals)) > 0:
+        raise TypeError(
+            f"Expected {len(param_defs) - found_ls} arguments, "
+            f"got {len(params.arguments)}"
+        )
+
+    return tuple(args), kwargs
+
+
+def _get_handler_params_annotations(handler: Callable[..., Any]):
+    """Return the parameters and corresponding type annotations for the given handler
+    function."""
+
+    try:
+        annotations = typing.get_type_hints(handler)
+        params = inspect.signature(handler).parameters
+    except TypeError:
+        # If the user's handler requests the language server instance, the real function
+        # is wrapped inside whatever `functools.partial()` returns.
+        if not hasattr(handler, "func"):
+            raise
+
+        annotations = typing.get_type_hints(handler.func)
+        params = inspect.signature(handler.func).parameters
+
+    return params, annotations


Test patch:
diff --git a/tests/e2e/test_commands.py b/tests/e2e/test_commands.py
new file mode 100644
index 0000000..d87e1aa
--- /dev/null
+++ b/tests/e2e/test_commands.py
@@ -0,0 +1,317 @@
+############################################################################
+# Copyright(c) Open Law Library. All rights reserved.                      #
+# See ThirdPartyNotices.txt in the project root for additional notices.    #
+#                                                                          #
+# Licensed under the Apache License, Version 2.0 (the "License")           #
+# you may not use this file except in compliance with the License.         #
+# You may obtain a copy of the License at                                  #
+#                                                                          #
+#     http: // www.apache.org/licenses/LICENSE-2.0                         #
+#                                                                          #
+# Unless required by applicable law or agreed to in writing, software      #
+# distributed under the License is distributed on an "AS IS" BASIS,        #
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
+# See the License for the specific language governing permissions and      #
+# limitations under the License.                                           #
+############################################################################
+from __future__ import annotations
+
+import logging
+import itertools
+import typing
+
+import pytest
+import pytest_asyncio
+from lsprotocol import types
+
+from pygls.exceptions import JsonRpcInternalError, JsonRpcInvalidParams
+
+if typing.TYPE_CHECKING:
+    from typing import Tuple
+
+    from pygls.lsp.client import LanguageClient
+
+
+logger = logging.getLogger(__name__)
+
+
+@pytest_asyncio.fixture(scope="module", loop_scope="module")
+async def commands(get_client_for):
+    async for result in get_client_for("commands.py"):
+        client, _ = result
+
+        @client.feature(types.WINDOW_LOG_MESSAGE)
+        def log(params: types.LogMessageParams):
+            logger.info(params.message)
+
+        yield result
+
+
+@pytest.mark.asyncio(loop_scope="module")
+async def test_command_not_defined(
+    commands: Tuple[LanguageClient, types.InitializeResult]
+):
+    """Ensure that the example commands server handles the case where
+    the requested command is not defined."""
+
+    client, _ = commands
+
+    with pytest.raises(
+        JsonRpcInvalidParams,
+        match="Invalid Params: Command name 'not.defined' is not defined",
+    ):
+        await client.workspace_execute_command_async(
+            types.ExecuteCommandParams(
+                command="not.defined",
+            )
+        )
+
+
+@pytest.mark.asyncio(loop_scope="module")
+@pytest.mark.parametrize("name", ["random", "random.wrapped"])
+async def test_random(
+    commands: Tuple[LanguageClient, types.InitializeResult], name: str
+):
+    """Ensure that the example commands server can execute both the wrapped and
+    unwrapped ``calculate.triangle.hypotenuse`` commands correctly."""
+
+    client, initialize_result = commands
+
+    provider = initialize_result.capabilities.execute_command_provider
+    assert name in provider.commands
+
+    result = await client.workspace_execute_command_async(
+        types.ExecuteCommandParams(
+            command=name,
+            arguments=None,
+        )
+    )
+
+    assert result == 4
+
+
+@pytest.mark.asyncio(loop_scope="module")
+@pytest.mark.parametrize("name", ["random", "random.wrapped"])
+async def test_random_invalid(
+    commands: Tuple[LanguageClient, types.InitializeResult], name: str
+):
+    """Ensure that the example commands server handles the case when a command is called
+    with too many arguments."""
+
+    client, initialize_result = commands
+
+    provider = initialize_result.capabilities.execute_command_provider
+    assert name in provider.commands
+
+    with pytest.raises(
+        JsonRpcInvalidParams, match="Invalid Params: Expected 0 arguments, got 1"
+    ):
+        await client.workspace_execute_command_async(
+            types.ExecuteCommandParams(
+                command=name,
+                arguments=[{"seed": 1234}],
+            )
+        )
+
+
+@pytest.mark.asyncio(loop_scope="module")
+@pytest.mark.parametrize(
+    "name, setup",
+    [
+        *itertools.product(
+            ["calculate.sum", "calculate.sum.wrapped"],
+            [
+                ([], 0),
+                ([1], 1),
+                ([1, 2], 3),
+                ([1, 2, 3], 6),
+            ],
+        ),
+    ],
+)
+async def test_calculate_sum(
+    commands: Tuple[LanguageClient, types.InitializeResult],
+    name: str,
+    setup: tuple[list[int], int],
+):
+    """Ensure that the example commands server can execute both the wrapped and
+    unwrapped ``calculate.sum`` commands correctly."""
+
+    client, initialize_result = commands
+    arguments, expected = setup
+
+    provider = initialize_result.capabilities.execute_command_provider
+    assert name in provider.commands
+
+    result = await client.workspace_execute_command_async(
+        types.ExecuteCommandParams(
+            command=name,
+            arguments=arguments,
+        )
+    )
+
+    assert result == expected
+
+
+@pytest.mark.asyncio(loop_scope="module")
+@pytest.mark.parametrize("name", ["calculate.pow", "calculate.pow.wrapped"])
+async def test_calculate_pow_invalid(
+    commands: Tuple[LanguageClient, types.InitializeResult], name: str
+):
+    """Ensure that the example commands server handles the case where not enough
+    arguments are given."""
+
+    client, initialize_result = commands
+
+    provider = initialize_result.capabilities.execute_command_provider
+    assert name in provider.commands
+
+    with pytest.raises(
+        JsonRpcInvalidParams, match="Invalid Params: Expected 2 arguments, got 1"
+    ):
+        await client.workspace_execute_command_async(
+            types.ExecuteCommandParams(
+                command=name,
+                arguments=[3],
+            )
+        )
+
+
+@pytest.mark.asyncio(loop_scope="module")
+@pytest.mark.parametrize(
+    "name",
+    ["calculate.pow", "calculate.pow.wrapped"],
+)
+async def test_calculate_pow(
+    commands: Tuple[LanguageClient, types.InitializeResult],
+    name: str,
+):
+    """Ensure that the example commands server can execute both the wrapped and
+    unwrapped ``calculate.pow`` commands correctly."""
+
+    client, initialize_result = commands
+
+    provider = initialize_result.capabilities.execute_command_provider
+    assert name in provider.commands
+
+    result = await client.workspace_execute_command_async(
+        types.ExecuteCommandParams(
+            command=name,
+            arguments=[2, 3],
+        )
+    )
+
+    assert result == 8
+
+
+@pytest.mark.asyncio(loop_scope="module")
+@pytest.mark.parametrize("name", ["calculate.div", "calculate.div.wrapped"])
+async def test_calculate_div_invalid(
+    commands: Tuple[LanguageClient, types.InitializeResult], name: str
+):
+    """Ensure that the example commands server handles the case where
+    there is a error in the command handler."""
+
+    client, initialize_result = commands
+
+    provider = initialize_result.capabilities.execute_command_provider
+    assert name in provider.commands
+
+    with pytest.raises(
+        JsonRpcInternalError, match="ZeroDivisionError: float division by zero"
+    ):
+        await client.workspace_execute_command_async(
+            types.ExecuteCommandParams(
+                command=name,
+                arguments=[3, 0],
+            )
+        )
+
+
+@pytest.mark.asyncio(loop_scope="module")
+@pytest.mark.parametrize(
+    "name",
+    ["calculate.div", "calculate.div.wrapped"],
+)
+async def test_calculate_div(
+    commands: Tuple[LanguageClient, types.InitializeResult],
+    name: str,
+):
+    """Ensure that the example commands server can execute both the wrapped and
+    unwrapped ``calculate.pow`` commands correctly."""
+
+    client, initialize_result = commands
+
+    provider = initialize_result.capabilities.execute_command_provider
+    assert name in provider.commands
+
+    result = await client.workspace_execute_command_async(
+        types.ExecuteCommandParams(
+            command=name,
+            arguments=[6, 3],
+        )
+    )
+
+    assert result == 2
+
+
+@pytest.mark.asyncio(loop_scope="module")
+@pytest.mark.parametrize(
+    "name",
+    [
+        "calculate.triangle.hypotenuse",
+        "calculate.triangle.hypotenuse.wrapped",
+        "calculate.untyped.hypotenuse",
+        "calculate.untyped.hypotenuse.wrapped",
+    ],
+)
+async def test_calculate_hypotenuse(
+    commands: Tuple[LanguageClient, types.InitializeResult], name: str
+):
+    """Ensure that the example commands server can execute both the wrapped and
+    unwrapped ``calculate.triangle.hypotenuse`` commands correctly."""
+
+    client, initialize_result = commands
+
+    provider = initialize_result.capabilities.execute_command_provider
+    assert name in provider.commands
+
+    result = await client.workspace_execute_command_async(
+        types.ExecuteCommandParams(
+            command=name,
+            arguments=[{"a": 3, "b": 4}],
+        )
+    )
+
+    assert result == {"a": 3, "b": 4, "c": 5}
+
+
+@pytest.mark.asyncio(loop_scope="module")
+@pytest.mark.parametrize(
+    "name",
+    [
+        "calculate.triangle.hypotenuse",
+        "calculate.triangle.hypotenuse.wrapped",
+    ],
+)
+async def test_calculate_hypotenuse_invalid(
+    commands: Tuple[LanguageClient, types.InitializeResult], name: str
+):
+    """Ensure that the example commands server handles the case where the given argument
+    does not align to the expected type
+    """
+
+    client, initialize_result = commands
+
+    provider = initialize_result.capabilities.execute_command_provider
+    assert name in provider.commands
+
+    with pytest.raises(
+        JsonRpcInvalidParams, match="Invalid Params: While structuring Triangle.*"
+    ):
+        await client.workspace_execute_command_async(
+            types.ExecuteCommandParams(
+                command=name,
+                arguments=[{"x": 3, "y": 4}],
+            )
+        )