This is an automated email from the ASF dual-hosted git repository.

yzheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris-tools.git


The following commit(s) were added to refs/heads/main by this push:
     new 345f868  [MCP] feat: Add local MCP client (#84)
345f868 is described below

commit 345f8684dcc2d5502684bb217adbee23cffb4172
Author: Yong Zheng <[email protected]>
AuthorDate: Tue Dec 2 22:27:39 2025 -0600

    [MCP] feat: Add local MCP client (#84)
    
    * MCP client
    
    * MCP client
    
    * Fix arg type and add examples
    
    * Fix arg type and add examples
    
    * Fix arg type and add examples
    
    * Fix lint
    
    * Move client.py to int_test/client.py
---
 .gitignore                       |   1 +
 mcp-server/README.md             |  85 +++++++++++++++
 mcp-server/int_test/client.py    | 216 +++++++++++++++++++++++++++++++++++++++
 mcp-server/polaris_mcp/server.py |  40 +++++++-
 4 files changed, 341 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index dd41931..600a009 100644
--- a/.gitignore
+++ b/.gitignore
@@ -82,6 +82,7 @@ mcp-server/apache_polaris_mcp.egg-info/
 mcp-server/polaris_mcp/__pycache__/
 mcp-server/polaris_mcp/tools/__pycache__/
 mcp-server/tests/__pycache__/
+mcp-server/__pycache__/
 
 # Maven flatten plugin
 .flattened-pom.xml
diff --git a/mcp-server/README.md b/mcp-server/README.md
index dc080e0..98f7998 100644
--- a/mcp-server/README.md
+++ b/mcp-server/README.md
@@ -62,6 +62,91 @@ For a `tools/call` invocation you will typically set 
environment variables such
 
 Please note: `--directory` specifies a local directory. It is not needed when 
we pull `polaris-mcp` from PyPI package.
 
+### MCP Client
+
+For quick local testing without configuring a full client like Claude Desktop, 
you can use the included `client.py` script.
+
+```bash
+# Start service in HTTP mode
+## Make sure you have set necessary environment variables (POLARIS_BASE_URL, 
etc.)
+uv run polaris-mcp --transport http
+# Start client in interactative mode
+uv run int_test/client.py http://localhost:8000/mcp
+```
+
+You can also run client directly from the command line with non-interactive 
mode:
+
+```bash
+uv run int_test/client.py http://localhost:8000/mcp --tool 
polaris-catalog-request --args '{"operation": "list"}'
+```
+
+Here are sample client commands:
+
+```bash
+# Create catalog
+uv run int_test/client.py http://localhost:8000/mcp \
+  --tool polaris-catalog-request \
+  --args '{
+    "operation": "create",
+    "body": {
+      "catalog": {
+        "name": "quickstart_catalog",
+        "type": "INTERNAL",
+        "readOnly": false,
+        "properties": {
+          "default-base-location": "s3://bucket123"
+        },
+        "storageConfigInfo": {
+          "storageType": "S3",
+          "allowedLocations": ["s3://bucket123"],
+          "endpoint": "http://localhost:9000";,
+          "pathStyleAccess": true
+        }
+      }
+    }
+  }'
+# List catalog
+uv run client.py http://localhost:8000/mcp \
+  --tool polaris-catalog-request \
+  --args '{"operation": "list"}'
+# Create principal
+uv run client.py http://localhost:8000/mcp \
+  --tool polaris-principal-request \
+  --args '{
+    "operation": "create",
+    "body": {
+      "principal": {
+        "name": "quickstart_user",
+        "properties": {}
+      }
+    }
+  }'
+# Create principal role
+uv run client.py http://localhost:8000/mcp \
+  --tool polaris-principal-role-request \
+  --args '{
+    "operation": "create",
+    "body": {
+      "principalRole": {
+        "name": "quickstart_user_role",
+        "properties": {}
+      }
+    }
+  }'
+# Assign principal role
+uv run client.py http://localhost:8000/mcp \
+  --tool polaris-principal-request \
+  --args '{
+    "operation": "assign-principal-role",
+    "principal": "quickstart_user",
+    "body": {
+      "principalRole": {
+        "name": "quickstart_user_role"
+      }
+    }
+  }'
+```
+
 ## Configuration
 
 | Variable                                                       | Description 
                                                     | Default                  
                        |
diff --git a/mcp-server/int_test/client.py b/mcp-server/int_test/client.py
new file mode 100644
index 0000000..9665e74
--- /dev/null
+++ b/mcp-server/int_test/client.py
@@ -0,0 +1,216 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+"""Local client for the Polaris MCP server."""
+
+from fastmcp import Client
+import asyncio
+import argparse
+import json
+import sys
+from typing import Any, Optional
+
+
+class McpClientError(Exception):
+    pass
+
+
+async def _prompt(prompt: str) -> str:
+    user_input = await asyncio.to_thread(input, f"{prompt}: ")
+    return user_input.strip()
+
+
+def _get_arg_type(schema: dict) -> str:
+    if "type" in schema:
+        return schema["type"]
+    if "anyOf" in schema:
+        for sub_schema in schema["anyOf"]:
+            sub_type = _get_arg_type(sub_schema)
+            if sub_type == "object":
+                return sub_type
+    return "string"
+
+
+async def _prompt_for_argument(
+    arg_name: str, schema_property: dict, is_required: bool
+) -> Any:
+    description = schema_property.get("description", "")
+    arg_type = _get_arg_type(schema_property)
+    enum_values = schema_property.get("enum")
+    enum_str = ", ".join(enum_values) if enum_values else ""
+
+    # Build prompt
+    parts = [f"Enter value for '{arg_name}'"]
+    if description:
+        parts.append(f"({description})")
+    if is_required:
+        parts.append("[REQUIRED]")
+    if enum_values:
+        parts.append(f"(options: {enum_str})")
+    prompt = " ".join(parts)
+    # Handle JSON input
+    if arg_type == "object":
+        while True:
+            value = await _prompt(prompt)
+            if not value:
+                return None
+            try:
+                return json.loads(value)
+            except json.JSONDecodeError:
+                print("Invalid JSON. Please try again.")
+    # Handle primitive types
+    while True:
+        value = await _prompt(prompt)
+        if not value:
+            if is_required:
+                print(f"{arg_name} is required.")
+                continue
+            return None
+
+        if enum_values and value not in enum_values:
+            print(f"Invalid option. Please choose from: {enum_str}")
+            continue
+        return value
+
+
+def _load_json_from_str_or_file(
+    json_str: Optional[str], json_file: Optional[str]
+) -> dict:
+    if json_str:
+        try:
+            return json.loads(json_str)
+        except json.JSONDecodeError:
+            raise McpClientError("Error: Invalid JSON string provided.")
+    elif json_file:
+        try:
+            with open(json_file) as f:
+                return json.load(f)
+        except (FileNotFoundError, json.JSONDecodeError) as e:
+            raise McpClientError(f"Error reading JSON file: {e}")
+    return {}
+
+
+async def _display(result: Any) -> None:
+    for content in result.content:
+        print(content.text if content.type == "text" else content.type)
+    if result.meta:
+        print("--- Meta ---")
+        print(json.dumps(result.meta, indent=2))
+
+
+async def _run_session(session: Any, args: argparse.Namespace) -> None:
+    def list_tools(tools):
+        print("Available Tools:")
+        for tool in tools:
+            print(f"- {tool.name}: {tool.description}")
+
+    # CLI mode
+    if args.tool:
+        tool_args = _load_json_from_str_or_file(args.args, args.args_file)
+        try:
+            result = await session.call_tool(args.tool, tool_args)
+            await _display(result)
+        except Exception as e:
+            raise McpClientError(f"Error running tool '{args.tool}': {e}")
+        return
+    # Interactive mode
+    tools = await session.list_tools()
+    if not tools:
+        print("No tools available on the MCP server.")
+        return
+    list_tools(tools)
+    while True:
+        print("-" * 20)
+        print(
+            "Select a tool by name, 'r' to refresh, 'q' to quit: ", end="", 
flush=True
+        )
+        choice = (await asyncio.to_thread(sys.stdin.readline)).strip()
+        if choice.lower() == "q":
+            break
+        if choice.lower() == "r":
+            print("Refreshing tool list...")
+            tools = await session.list_tools()
+            list_tools(tools)
+            continue
+        selected_tool = next(
+            (tool for tool in tools if tool.name.lower() == choice.lower()), 
None
+        )
+        if not selected_tool:
+            print(f"Tool '{choice}' not found. Please try again.")
+            continue
+        # Argument for interactive mode
+        input_schema = selected_tool.inputSchema
+        props = input_schema.get("properties", {})
+        required = input_schema.get("required", [])
+        arguments = {}
+        if required:
+            print("\n--- Required Arguments ---")
+            for arg_name in required:
+                schema = props.get(arg_name, {})
+                value = await _prompt_for_argument(arg_name, schema, 
is_required=True)
+                if value:
+                    arguments[arg_name] = value
+        optional_args = {k: v for k, v in props.items() if k not in required}
+        if optional_args:
+            print("\n--- Optional Arguments ---")
+            for arg_name, schema in optional_args.items():
+                value = await _prompt_for_argument(arg_name, schema, 
is_required=False)
+                if value:
+                    arguments[arg_name] = value
+        print(
+            f"\nRunning tool '{selected_tool.name}' with arguments:\n"
+            f"{json.dumps(arguments, indent=2)}"
+        )
+        try:
+            result = await session.call_tool(selected_tool.name, arguments)
+            await _display(result)
+        except Exception as e:
+            print(f"Error running tool '{selected_tool.name}': {e}")
+
+
+async def run():
+    parser = argparse.ArgumentParser(description="Polaris MCP Client")
+    parser.add_argument(
+        "server", help="MCP server. Can be a local .py file, or an HTTP/SSE 
URL."
+    )
+    parser.add_argument("--tool", help="Tool to run directly (skips 
interactive mode).")
+    parser.add_argument(
+        "--args", help="JSON string of arguments for the tool (used with 
--tool)."
+    )
+    parser.add_argument(
+        "--args-file",
+        help="Path to JSON file with arguments for the tool (used with 
--tool).",
+    )
+    args = parser.parse_args()
+    server = args.server.strip()
+    if not (server.endswith(".py") or server.startswith(("http://";, 
"https://";))):
+        raise McpClientError(f"Error: '{server}' must be a .py file or an 
URL.")
+    async with Client(server) as session:
+        await _run_session(session, args)
+
+
+if __name__ == "__main__":
+    try:
+        asyncio.run(run())
+    except (KeyboardInterrupt, McpClientError) as e:
+        if isinstance(e, McpClientError):
+            print(e, file=sys.stderr)
+            sys.exit(1)
+        print("\nExiting...")
+        sys.exit(0)
diff --git a/mcp-server/polaris_mcp/server.py b/mcp-server/polaris_mcp/server.py
index ca1e5a5..a338612 100644
--- a/mcp-server/polaris_mcp/server.py
+++ b/mcp-server/polaris_mcp/server.py
@@ -21,8 +21,10 @@
 
 from __future__ import annotations
 
+import sys
 import logging
 import logging.config
+import argparse
 import os
 from typing import Any, Mapping, MutableMapping, Sequence, Optional
 from urllib.parse import urljoin, urlparse
@@ -548,7 +550,43 @@ def main() -> None:
 
     logging.config.dictConfig(LOGGING_CONFIG)
     server = create_server()
-    server.run()
+
+    parser = argparse.ArgumentParser(description="Run Apache Polaris MCP 
Server")
+    parser.add_argument(
+        "--transport",
+        choices=["stdio", "sse", "http"],
+        default="stdio",
+        help="Transport type to use (default: stdio)",
+    )
+    parser.add_argument(
+        "--host",
+        default="127.0.0.1",
+        help="Host for SSE/HTTP transportS (default: 127.0.0.1)",
+    )
+    parser.add_argument(
+        "--port",
+        type=int,
+        default=8000,
+        help="Port for SSE/HTTP transports (default: 8000)",
+    )
+    args = parser.parse_args()
+
+    if args.transport == "stdio":
+        logger.info("Starting Apache Polaris MCP server using STDIO transport")
+        server.run()
+    elif args.transport == "sse":
+        logger.info(
+            f"Starting Apache Polaris MCP server using SSE transport on 
http://{args.host}:{args.port}/sse";
+        )
+        server.run(transport="sse", host=args.host, port=args.port)
+    elif args.transport == "http":
+        logger.info(
+            f"Starting Apache Polaris MCP server using HTTP transport on 
http://{args.host}:{args.port}/mcp";
+        )
+        server.run(transport="http", host=args.host, port=args.port, 
path="/mcp")
+    else:
+        logger.error(f"Unknown transport: {args.transport}")
+        sys.exit(1)
 
 
 if __name__ == "__main__":  # pragma: no cover

Reply via email to