Skip to content

Commit 9370067

Browse files
committed
feat: Add hybrid mode and improve logging levels
Major changes: - Add hybrid mode to run Magg with both stdio and HTTP simultaneously - New --hybrid flag for `magg serve` command - Concurrent task management for both transports - Useful for mbro hosting Magg while using stdio connection - Convert verbose INFO logs to DEBUG level throughout codebase - Server mounting/unmounting operations - Config reload operations - Authentication key generation - File watcher initialization - Improve mbro CLI behavior - Clean exit on Ctrl+C with empty buffer - Better multiline continuation handling - Consistent error message formatting with repr() - Add documentation for hybrid mode - Examples for Claude Code integration - Examples for mbro hosting scenarios - Updated readme.md with third running mode - Bump version to 0.9.1 This release improves production readiness with quieter logging and enables powerful hybrid connectivity scenarios. Signed-off-by: Phillip Sitbon <phillip.sitbon@gmail.com>
1 parent 25d1e08 commit 9370067

File tree

16 files changed

+202
-72
lines changed

16 files changed

+202
-72
lines changed

.github/workflows/publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ jobs:
131131
132132
# Get changelog since last release with rich formatting
133133
echo "CHANGELOG<<EOFEOF" >> $GITHUB_ENV
134-
git log ${LATEST_TAG}..${{ github.sha }} --pretty=format:'### [%s](https://github.com/${{ github.repository }}/commit/%H)%nDate: %ad*%n%n%b%n' | sed '/^Signed-off-by:/d' | sed 's/^$/>/g' >> $GITHUB_ENV
134+
git log ${LATEST_TAG}..${{ github.sha }} --pretty=format:'### [%s](https://github.com/${{ github.repository }}/commit/%H)%nDate: %ad%n%n%b%n' | sed '/^Signed-off-by:/d' | sed 's/^$/>/g' >> $GITHUB_ENV
135135
echo "EOFEOF" >> $GITHUB_ENV
136136
137137
# Delete remote latest-publish tag FIRST (before creating new one)

docs/index.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,37 @@ magg serve
112112
# Or for HTTP mode
113113
magg serve --http
114114

115+
# Or for hybrid mode (both stdio and HTTP simultaneously)
116+
magg serve --hybrid
117+
magg serve --hybrid --port 8080 # Custom port
118+
115119
# Or run directly without installation (if you have uvx)
116120
uvx magg serve
117121
```
118122

123+
#### Hybrid Mode
124+
125+
Magg supports running in hybrid mode where it accepts connections via both stdio and HTTP simultaneously. This is particularly useful when you want to:
126+
127+
- Use Magg through an MCP client (stdio) while also accessing it via HTTP API
128+
- Have mbro host the server while connecting to it via stdio
129+
- Test both interfaces without running multiple instances
130+
131+
**Example with mbro:**
132+
```bash
133+
# mbro can host Magg in hybrid mode and connect to it via stdio
134+
mbro connect magg "magg serve --hybrid --port 8080"
135+
136+
# Now Magg is:
137+
# - Connected to mbro via stdio
138+
# - Also accessible via HTTP at http://localhost:8080
139+
140+
# Other mbro instances can now connect via HTTP
141+
mbro connect magg-remote http://localhost:8080
142+
```
143+
144+
This allows you to use mbro's interactive features while other clients (including other mbro instances) can connect via HTTP.
145+
119146
## Usage Flows
120147

121148
### 1. Discovering and Adding Servers

examples/config_reload.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,21 @@ async def demo_config_reload():
2424
config_path = Path(".magg") / "config.json"
2525

2626
logger.setLevel(logging.INFO)
27-
logger.info("Starting Magg server with config reloading enabled")
28-
logger.info("Config path: %s", config_path)
29-
logger.info("You can:")
30-
logger.info(" 1. Modify the config file to see automatic reload")
31-
logger.info(" 2. Send SIGHUP signal to trigger reload: kill -HUP %d", os.getpid())
32-
logger.info(" 3. Use the magg_reload_config tool via MCP client")
33-
logger.info("")
34-
logger.info("Press Ctrl+C to stop")
35-
36-
# Create runner with signal handling
27+
logger.info(
28+
"""Starting Magg server with config reloading enabled
29+
Config path: %s
30+
You can:
31+
1. Modify the config file to see automatic reload
32+
2. Send SIGHUP signal to trigger reload: kill -HUP %d
33+
3. Use the magg_reload_config tool via MCP client
34+
35+
Press Ctrl+C to stop""",
36+
config_path, os.getpid(),
37+
)
38+
3739
runner = MaggRunner(config_path)
3840

3941
try:
40-
# Run the server
4142
await runner.run_http("localhost", 8000)
4243
except KeyboardInterrupt:
4344
logger.info("Shutting down...")

magg/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def _generate_keypair(self) -> Optional[rsa.RSAPrivateKey]:
111111
format=serialization.PublicFormat.OpenSSH
112112
))
113113

114-
logger.info("Generated new RSA keypair in %s", self.bearer_config.key_path)
114+
logger.debug("Generated new RSA keypair in %s", self.bearer_config.key_path)
115115
return private_key
116116

117117
except Exception as e:

magg/cli.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,18 @@
3333

3434
async def cmd_serve(args) -> None:
3535
"""Start Magg server."""
36+
mode = 'hybrid' if args.hybrid else 'http' if args.http else 'stdio'
37+
logger.info("Starting Magg server (mode: %s)", mode)
3638

37-
logger.info("Starting Magg server (mode: %s)", 'http' if args.http else 'stdio')
38-
39-
if args.http:
39+
if args.http or args.hybrid:
4040
print_startup_banner()
4141

4242
runner = MaggRunner(args.config)
4343

44-
if args.http:
44+
if args.hybrid:
45+
logger.info("Starting hybrid server (stdio + HTTP on %s:%s)", args.host, args.port)
46+
await runner.run_hybrid(host=args.host, port=args.port)
47+
elif args.http:
4548
logger.info("Starting HTTP server on %s:%s", args.host, args.port)
4649
await runner.run_http(host=args.host, port=args.port)
4750
else:
@@ -55,6 +58,11 @@ def cmd_serve_args(parser: argparse.ArgumentParser) -> None:
5558
action='store_true',
5659
help='Run as HTTP server instead of stdio mode'
5760
)
61+
parser.add_argument(
62+
'--hybrid',
63+
action='store_true',
64+
help='Run in hybrid mode (both stdio and HTTP)'
65+
)
5866
parser.add_argument(
5967
'--host',
6068
type=str,
@@ -166,7 +174,7 @@ async def cmd_remove_server(args) -> None:
166174
config.remove_server(args.name)
167175

168176
if config_manager.save_config(config):
169-
logger.info("Successfully removed server '%s'", args.name)
177+
logger.debug("Successfully removed server '%s'", args.name)
170178
print_success(f"Removed server '{args.name}'")
171179
else:
172180
print_error("Failed to save configuration")

magg/kit.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,11 @@ def load_kits_from_config(self, config: 'MaggConfig') -> None:
167167
kit_config = self.load_kit(kit_path)
168168
if kit_config:
169169
self.add_kit(kit_name, kit_config)
170-
logger.info("Loaded kit %r from %s", kit_name, kit_path)
170+
logger.debug("Loaded kit %r from %s", kit_name, kit_path)
171171
else:
172172
logger.error("Failed to load kit %r from %s", kit_name, kit_path)
173173
else:
174-
logger.info("Kit %r not found in any kit.d directory - creating in memory", kit_name)
174+
logger.debug("Kit %r not found in any kit.d directory - creating in memory", kit_name)
175175
kit_config = KitConfig(name=kit_name)
176176
self.add_kit(kit_name, kit_config)
177177

magg/mbro/cli.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -146,30 +146,39 @@ def _create_key_bindings(self):
146146

147147
@kb.add('c-c')
148148
def _(event):
149-
"""Handle Ctrl+C - cancel completion or multiline input or interrupt."""
149+
"""Handle Ctrl+C - cancel completion, clear buffer, or exit cleanly."""
150150
buffer = event.app.current_buffer
151151
if buffer.complete_state:
152152
buffer.cancel_completion()
153153
elif buffer.text.strip():
154154
buffer.reset()
155155
else:
156-
raise KeyboardInterrupt()
156+
# Exit cleanly when buffer is empty
157+
event.app.exit()
157158

158159
@kb.add('enter')
159160
def _(event):
160-
"""Handle Enter key - submit complete commands immediately."""
161+
"""Handle Enter key - submit on empty line or continue multiline."""
161162
buffer = event.app.current_buffer
162163
document = buffer.document
163164
text = document.text.strip()
165+
current_line = document.current_line.strip()
166+
167+
# Empty buffer - just submit (prevents multiline on empty enter)
168+
if not text:
169+
buffer.validate_and_handle()
170+
return
164171

165172
validator_instance = self._create_input_validator()
166173

174+
# Single line command that's complete - submit immediately
167175
if text and not validator_instance._needs_continuation(text):
168176
if '\n' not in document.text:
169177
buffer.validate_and_handle()
170178
return
171179

172-
if not buffer.document.current_line.strip() and buffer.text.strip():
180+
# Multiline: empty line after content means submit
181+
if not current_line and text:
173182
buffer.validate_and_handle()
174183
else:
175184
buffer.insert_text('\n')
@@ -352,8 +361,7 @@ async def start(self, repl: bool = False):
352361
await self.handle_command(command)
353362

354363
except KeyboardInterrupt:
355-
if not self.formatter.json_only:
356-
self.formatter.print("\nUse 'quit' to exit.")
364+
continue
357365
except CancelledError:
358366
pass
359367
except EOFError:
@@ -391,7 +399,7 @@ async def handle_command(self, command: str):
391399
cmd = self.ALIASES.get(cmd, cmd)
392400

393401
if cmd not in self.COMMANDS:
394-
self.formatter.format_error(f"Unknown command: {cmd}")
402+
self.formatter.format_error(f"Unknown command: {cmd!r}")
395403
self.formatter.format_info("Type 'help' for available commands")
396404
return
397405

@@ -441,11 +449,9 @@ async def handle_commands(cli: MCPBrowserCLI, args) -> bool:
441449
class ScriptAction(argparse.Action):
442450
"""Custom action to track script execution order."""
443451
def __call__(self, parser, namespace, values, option_string=None):
444-
# Initialize script_order list if not present
445452
if not hasattr(namespace, 'script_order'):
446453
namespace.script_order = []
447-
448-
# Track order with type indicator
454+
449455
is_non_interactive = option_string in ['-X', '--execute-script-n']
450456
namespace.script_order.append((values, is_non_interactive))
451457

@@ -486,7 +492,7 @@ async def main_async():
486492
has_non_interactive_script = any(is_non_interactive for _, is_non_interactive in args.script_order)
487493
if has_non_interactive_script:
488494
args.no_interactive = True
489-
495+
490496
for script_path, _ in args.script_order:
491497
await cli.handle_command(f"script run {script_path}")
492498

@@ -519,10 +525,8 @@ def main():
519525

520526
try:
521527
asyncio.run(main_async())
522-
523528
except KeyboardInterrupt:
524529
pass
525-
526530
except Exception as e:
527531
print(f"Error: {e}", file=sys.stderr)
528532
exit(1)

magg/mbro/command.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ async def connect(self, args: list):
7171

7272
await self.cli.refresh_completer_cache()
7373
else:
74-
self.formatter.format_error(f"Failed to connect to '{name}'")
74+
self.formatter.format_error(f"Failed to connect to {name!r}")
7575

7676
async def switch(self, args: list):
7777
"""Switch to a different connection."""
@@ -84,7 +84,7 @@ async def switch(self, args: list):
8484
if success:
8585
await self.cli.refresh_completer_cache()
8686
else:
87-
self.formatter.format_error(f"Failed to switch to '{name}'")
87+
self.formatter.format_error(f"Failed to switch to {name!r}")
8888

8989
async def disconnect(self, args: list):
9090
"""Disconnect from a server."""
@@ -95,7 +95,7 @@ async def disconnect(self, args: list):
9595
name = args[0]
9696
success = await self.browser.remove_connection(name)
9797
if not success:
98-
self.formatter.format_error(f"Connection '{name}' not found")
98+
self.formatter.format_error(f"Connection {name!r} not found")
9999

100100
async def status(self):
101101
"""Get status of the current connection.
@@ -231,7 +231,7 @@ async def call(self, args: list):
231231
if required:
232232
missing = [param for param in required if param not in arguments]
233233
if missing:
234-
self.formatter.format_error(f"Tool '{tool_name}' missing required parameters: {missing}")
234+
self.formatter.format_error(f"Tool {tool_name!r} missing required parameters: {missing}")
235235

236236
properties = schema.get('properties', {})
237237
if properties and not self.formatter.json_only:
@@ -375,7 +375,7 @@ async def info(self, args: list):
375375
tools = await conn.get_tools()
376376
tool = next((t for t in tools if t["name"] == name), None)
377377
if not tool:
378-
self.formatter.format_error(f"Tool '{name}' not found.")
378+
self.formatter.format_error(f"Tool {name!r} not found.")
379379
return
380380

381381
self.formatter.format_tool_info(tool)
@@ -384,7 +384,7 @@ async def info(self, args: list):
384384
resources = await conn.get_resources()
385385
resource = next((r for r in resources if r["name"] == name), None)
386386
if not resource:
387-
self.formatter.format_error(f"Resource '{name}' not found.")
387+
self.formatter.format_error(f"Resource {name!r} not found.")
388388
return
389389

390390
self.formatter.format_resource_info(resource)
@@ -393,7 +393,7 @@ async def info(self, args: list):
393393
prompts = await conn.get_prompts()
394394
prompt = next((p for p in prompts if p["name"] == name), None)
395395
if not prompt:
396-
self.formatter.format_error(f"Prompt '{name}' not found.")
396+
self.formatter.format_error(f"Prompt {name!r} not found.")
397397
return
398398

399399
self.formatter.format_prompt_info(prompt)
@@ -430,7 +430,7 @@ async def _handle_proxy_query_result(self, tool_name: str, result: list) -> bool
430430
proxy_type = getattr(result.annotations, "proxyType", None)
431431

432432
if (result := ProxyMCP.get_proxy_query_result(result)) is None:
433-
self.formatter.format_error(f"Failed to handle apparent proxy query result for tool '{tool_name}'")
433+
self.formatter.format_error(f"Failed to handle apparent proxy query result for tool {tool_name!r}")
434434
return True
435435

436436
match proxy_type:

magg/mbro/parser.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,14 @@ def parse_command_line(line: str) -> list[str]:
130130
if not cleaned.strip():
131131
return []
132132

133+
# Strip trailing backslash from single-line continuation
134+
cleaned = cleaned.rstrip()
135+
if cleaned.endswith('\\'):
136+
cleaned = cleaned[:-1].rstrip()
137+
138+
if not cleaned:
139+
return []
140+
133141
try:
134142
return shlex.split(cleaned)
135143
except ValueError:

magg/mbro/scripts.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ async def handle_script_command(self, args: List[str]):
8888
elif subcmd == "dump":
8989
await self.dump_script(subargs)
9090
else:
91-
self.formatter.format_error(f"Unknown script command: {subcmd}\nCommands: run, list, search, dump")
91+
self.formatter.format_error(f"Unknown script command: {subcmd!r}\nCommands: run, list, search, dump")
9292

9393
async def run_script(self, args: List[str]):
9494
"""Run a script file."""
@@ -100,7 +100,7 @@ async def run_script(self, args: List[str]):
100100

101101
script_path = self.find_script(script_ref)
102102
if not script_path:
103-
self.formatter.format_error(f"Script not found: {script_ref}")
103+
self.formatter.format_error(f"Script not found: {script_ref!r}")
104104
exit(1)
105105

106106
try:
@@ -201,7 +201,7 @@ async def dump_script(self, args: List[str]):
201201

202202
script_path = self.find_script(script_ref)
203203
if not script_path:
204-
self.formatter.format_error(f"Script not found: {script_ref}")
204+
self.formatter.format_error(f"Script not found: {script_ref!r}")
205205
exit(1)
206206

207207
try:

0 commit comments

Comments
 (0)