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