maple-mcp 0.1.0__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.
maple/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """MAPLE — Model-Agnostic Platform for Laboratory Experiments."""
2
+
3
+ __version__ = "0.1.0"
maple/attach.py ADDED
@@ -0,0 +1,128 @@
1
+ """MAPLE Attach TUI — tail live logs from running services."""
2
+
3
+ from pathlib import Path
4
+
5
+ from textual.app import App, ComposeResult
6
+ from textual.containers import Grid
7
+ from textual.widgets import RichLog, Static
8
+
9
+ LOG_DIR = Path.home() / ".maple" / "logs"
10
+
11
+
12
+ class ServiceLogPane(Static):
13
+ """A single service's log pane with status header and scrollable log."""
14
+
15
+ DEFAULT_CSS = """
16
+ ServiceLogPane {
17
+ border: solid gray;
18
+ height: 1fr;
19
+ }
20
+ ServiceLogPane Static {
21
+ height: 1;
22
+ background: $panel;
23
+ padding: 0 1;
24
+ }
25
+ ServiceLogPane RichLog {
26
+ height: 1fr;
27
+ }
28
+ """
29
+
30
+ def __init__(self, service_name: str, **kwargs):
31
+ super().__init__(**kwargs)
32
+ self.service_name = service_name
33
+
34
+ def compose(self) -> ComposeResult:
35
+ yield Static(f"[green]● {self.service_name}[/]", id=f"status-{self.service_name}")
36
+ yield RichLog(id=f"log-{self.service_name}", highlight=True, markup=False, wrap=True)
37
+
38
+
39
+ class MapleAttachApp(App):
40
+ """TUI for tailing MAPLE service logs in split panes."""
41
+
42
+ TITLE = "MAPLE — Service Logs"
43
+ CSS = """
44
+ Grid { grid-size: 2; height: 1fr; }
45
+ """
46
+ BINDINGS = [("q", "quit", "Quit"), ("ctrl+c", "quit", "Quit")]
47
+
48
+ def __init__(self, services: list[str] = None, **kwargs):
49
+ super().__init__(**kwargs)
50
+ self._services = services or self._discover_services()
51
+
52
+ def _discover_services(self) -> list[str]:
53
+ """Find services that are currently running (have active PIDs)."""
54
+ pid_dir = Path.home() / ".maple" / "pids"
55
+ if not pid_dir.exists():
56
+ return []
57
+ return [f.stem for f in sorted(pid_dir.glob("*.pid"))]
58
+
59
+ def compose(self) -> ComposeResult:
60
+ if not self._services:
61
+ yield Static("[dim]No services found. Run 'maple serve' first.[/]")
62
+ return
63
+ with Grid():
64
+ for name in self._services:
65
+ yield ServiceLogPane(name, id=f"pane-{name}")
66
+
67
+ async def on_mount(self) -> None:
68
+ self._offsets: dict[str, int] = {}
69
+ for name in self._services:
70
+ path = LOG_DIR / f"{name}.log"
71
+ path.touch(exist_ok=True)
72
+ self._offsets[name] = path.stat().st_size
73
+ self._show_tail(path, self.query_one(f"#log-{name}", RichLog), lines=20)
74
+ # Single watcher on the logs directory
75
+ self.run_worker(self._watch_logs(), exclusive=True)
76
+
77
+ async def _watch_logs(self) -> None:
78
+ """Poll log files for changes every 500ms. Simple and reliable."""
79
+ import asyncio
80
+
81
+ try:
82
+ while True:
83
+ await asyncio.sleep(0.5)
84
+ for name in self._services:
85
+ path = LOG_DIR / f"{name}.log"
86
+ if not path.exists():
87
+ continue
88
+ current_size = path.stat().st_size
89
+ if current_size != self._offsets.get(name, 0):
90
+ log = self.query_one(f"#log-{name}", RichLog)
91
+ self._offsets[name] = self._flush_new_lines(
92
+ path, log, self._offsets[name]
93
+ )
94
+ except Exception:
95
+ pass
96
+
97
+ def _show_tail(self, path: Path, log: RichLog, lines: int = 20):
98
+ """Show the last N lines of the file on attach."""
99
+ try:
100
+ content = path.read_text(errors="replace")
101
+ recent = content.splitlines()[-lines:]
102
+ for line in recent:
103
+ log.write(line)
104
+ except Exception:
105
+ pass
106
+
107
+ def _flush_new_lines(self, path: Path, log: RichLog, offset: int) -> int:
108
+ """Read new content since last offset and write to log."""
109
+ try:
110
+ size = path.stat().st_size
111
+ if size < offset:
112
+ # File was truncated/rotated
113
+ offset = 0
114
+ with open(path, "r", errors="replace") as f:
115
+ f.seek(offset)
116
+ new_data = f.read()
117
+ new_offset = f.tell()
118
+ for line in new_data.splitlines():
119
+ log.write(line)
120
+ return new_offset
121
+ except Exception:
122
+ return offset
123
+
124
+
125
+ def run_attach(services: list[str] = None):
126
+ """Launch the attach TUI."""
127
+ app = MapleAttachApp(services=services)
128
+ app.run()
maple/auth.py ADDED
@@ -0,0 +1,63 @@
1
+ """MAPLE authentication — auto-generated device tokens for session identity."""
2
+
3
+ import os
4
+ import re
5
+ import uuid
6
+ from pathlib import Path
7
+
8
+
9
+ CREDENTIALS_FILE = Path.home() / ".maple" / "credentials"
10
+ _UUID_PATTERN = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$')
11
+
12
+
13
+ def get_or_create_token() -> str:
14
+ """Get the device token, creating one on first run.
15
+
16
+ Tokens are UUIDv4 strings stored in ~/.maple/credentials.
17
+ Used as bearer tokens for MCP server auth, keying session state.
18
+
19
+ Returns:
20
+ The device's unique token string.
21
+ """
22
+ if CREDENTIALS_FILE.exists():
23
+ token = CREDENTIALS_FILE.read_text().strip()
24
+ if token and is_valid_token(token):
25
+ return token
26
+
27
+ # Generate new token
28
+ token = str(uuid.uuid4())
29
+ CREDENTIALS_FILE.parent.mkdir(parents=True, exist_ok=True)
30
+
31
+ # Atomic write: write to temp file then rename
32
+ import tempfile
33
+ fd, tmp_path = tempfile.mkstemp(dir=CREDENTIALS_FILE.parent, prefix=".maple_cred_")
34
+ try:
35
+ os.write(fd, token.encode())
36
+ os.fchmod(fd, 0o600)
37
+ os.close(fd)
38
+ os.replace(tmp_path, CREDENTIALS_FILE)
39
+ except Exception:
40
+ try:
41
+ os.close(fd)
42
+ except OSError:
43
+ pass
44
+ try:
45
+ os.unlink(tmp_path)
46
+ except OSError:
47
+ pass
48
+ # Fallback: direct write
49
+ CREDENTIALS_FILE.write_text(token)
50
+ try:
51
+ os.chmod(CREDENTIALS_FILE, 0o600)
52
+ except OSError:
53
+ pass
54
+
55
+ return token
56
+
57
+
58
+ def is_valid_token(token: str) -> bool:
59
+ """Validate that a token is a well-formed UUIDv4.
60
+
61
+ Prevents arbitrary/malformed strings from becoming dict keys.
62
+ """
63
+ return bool(_UUID_PATTERN.match(token.lower()))
maple/cli.py ADDED
@@ -0,0 +1,220 @@
1
+ """MAPLE CLI — Command-line interface for serving and chatting with agents."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ app = typer.Typer(
7
+ name="maple",
8
+ help="MAPLE — Model-Agnostic Platform for Laboratory Experiments",
9
+ no_args_is_help=True,
10
+ )
11
+ serve_app = typer.Typer(help="Start MAPLE services in the background.")
12
+ chat_app = typer.Typer(help="Chat with a MAPLE agent via TUI.")
13
+ app.add_typer(serve_app, name="serve")
14
+ app.add_typer(chat_app, name="chat")
15
+
16
+ console = Console()
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # maple serve <subcommand>
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ @serve_app.command("all")
25
+ def serve_all(
26
+ dev: bool = typer.Option(False, "--dev", help="Enable auto-reload on file changes"),
27
+ config: str = typer.Option("maple.config.yaml", help="Path to config file"),
28
+ ):
29
+ """Start all MCP servers and agents (Operator + Overseer)."""
30
+ _do_serve(agent=None, stub=False, dev=dev, config=config)
31
+
32
+
33
+ @serve_app.command("operator")
34
+ def serve_operator(
35
+ dev: bool = typer.Option(False, "--dev", help="Enable auto-reload on file changes"),
36
+ config: str = typer.Option("maple.config.yaml", help="Path to config file"),
37
+ ):
38
+ """Start Operator MCP server and agent."""
39
+ _do_serve(agent="operator", stub=False, dev=dev, config=config)
40
+
41
+
42
+ @serve_app.command("overseer")
43
+ def serve_overseer(
44
+ dev: bool = typer.Option(False, "--dev", help="Enable auto-reload on file changes"),
45
+ config: str = typer.Option("maple.config.yaml", help="Path to config file"),
46
+ ):
47
+ """Start Overseer MCP server and agent."""
48
+ _do_serve(agent="overseer", stub=False, dev=dev, config=config)
49
+
50
+
51
+ @serve_app.command("stub")
52
+ def serve_stub(
53
+ dev: bool = typer.Option(False, "--dev", help="Enable auto-reload on file changes"),
54
+ config: str = typer.Option("maple.config.yaml", help="Path to config file"),
55
+ ):
56
+ """Start full demo stack (stub node + Operator MCP + mock agent)."""
57
+ _do_serve(agent=None, stub=True, dev=dev, config=config)
58
+
59
+
60
+ @serve_app.command("mock")
61
+ def serve_mock(
62
+ dev: bool = typer.Option(False, "--dev", help="Enable auto-reload on file changes"),
63
+ ):
64
+ """Start mock agent only (for demo/testing)."""
65
+ _do_serve(agent="mock", stub=False, dev=dev, config="maple.config.yaml")
66
+
67
+
68
+ def _do_serve(agent: str = None, stub: bool = False, dev: bool = False, config: str = "maple.config.yaml"):
69
+ """Shared serve logic."""
70
+ from dotenv import load_dotenv
71
+ load_dotenv(override=True)
72
+
73
+ from maple.services import start_services
74
+
75
+ started = start_services(agent=agent, config=config, stub=stub, dev=dev)
76
+
77
+ if not started:
78
+ console.print("[red]No services to start.[/red]")
79
+ raise typer.Exit(1)
80
+
81
+ console.print()
82
+ for name, port, pid in started:
83
+ console.print(f" [green]✓[/green] {name:20s} :{port} (pid {pid})")
84
+ console.print()
85
+ if dev:
86
+ console.print("[yellow]Dev mode: services will auto-reload on file changes[/yellow]")
87
+ console.print("[dim]Logs: ~/.maple/logs/[/dim]")
88
+ console.print("[dim]Stop: maple down[/dim]")
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # maple chat <subcommand>
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ @chat_app.command("operator")
97
+ def chat_operator(
98
+ host: str = typer.Option("localhost", help="Agent service host"),
99
+ resume: bool = typer.Option(False, "--resume", help="Resume most recent operator session"),
100
+ ):
101
+ """Chat with the Operator agent."""
102
+ from dotenv import load_dotenv
103
+ load_dotenv(override=True)
104
+ from maple.tui import run_chat
105
+ session_id = _get_session_id("operator", resume)
106
+ run_chat(agent="operator", host=host, session_id=session_id)
107
+
108
+
109
+ @chat_app.command("overseer")
110
+ def chat_overseer(
111
+ host: str = typer.Option("localhost", help="Agent service host"),
112
+ resume: bool = typer.Option(False, "--resume", help="Resume most recent overseer session"),
113
+ ):
114
+ """Chat with the Overseer agent."""
115
+ from dotenv import load_dotenv
116
+ load_dotenv(override=True)
117
+ from maple.tui import run_chat
118
+ session_id = _get_session_id("overseer", resume)
119
+ run_chat(agent="overseer", host=host, session_id=session_id)
120
+
121
+
122
+ def _get_session_id(agent: str, resume: bool) -> str:
123
+ """Get or create a session ID for the chat.
124
+
125
+ If resume=True, finds the most recent session file for the agent.
126
+ Otherwise generates a new UUID.
127
+ """
128
+ import uuid
129
+ from pathlib import Path
130
+
131
+ sessions_dir = Path.home() / ".maple" / "sessions" / agent
132
+
133
+ if resume and sessions_dir.exists():
134
+ session_dirs = [d for d in sessions_dir.iterdir() if d.is_dir() and d.name.startswith("session_")]
135
+ if session_dirs:
136
+ latest = max(session_dirs, key=lambda d: d.stat().st_mtime)
137
+ # Extract UUID from "session_{uuid}" directory name
138
+ session_id = latest.name.removeprefix("session_")
139
+ console.print(f"[dim]Resuming session: {session_id[:8]}...[/dim]")
140
+ return session_id
141
+ else:
142
+ console.print("[dim]No previous session found. Starting new.[/dim]")
143
+
144
+ return str(uuid.uuid4())
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # maple down
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ @app.command()
153
+ def down():
154
+ """Stop all running MAPLE services."""
155
+ from maple.services import stop_services
156
+
157
+ stopped = stop_services()
158
+
159
+ if stopped:
160
+ console.print(f"[green]✓[/green] Stopped {len(stopped)} service(s): {', '.join(stopped)}")
161
+ else:
162
+ console.print("[dim]No running services found.[/dim]")
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # maple logs
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ @app.command()
171
+ def logs():
172
+ """View live service logs in a TUI (tail mode)."""
173
+ from maple.attach import run_attach
174
+ run_attach()
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # maple status
179
+ # ---------------------------------------------------------------------------
180
+
181
+
182
+ @app.command()
183
+ def status():
184
+ """Check status of running MAPLE services."""
185
+ import httpx
186
+ from maple.services import get_status
187
+
188
+ running = get_status()
189
+
190
+ if not running:
191
+ console.print("[dim]No MAPLE services registered. Run 'maple serve' first.[/dim]")
192
+ return
193
+
194
+ console.print()
195
+ for name, pid, is_running in running:
196
+ if is_running:
197
+ console.print(f" [green]✓[/green] {name:20s} (pid {pid})")
198
+ else:
199
+ console.print(f" [red]✗[/red] {name:20s} (pid {pid} — dead)")
200
+ console.print()
201
+
202
+ # Also try pinging known endpoints
203
+ endpoints = {
204
+ "operator-mcp": "http://localhost:8102/ping",
205
+ "overseer-mcp": "http://localhost:8103/ping",
206
+ "operator-agent": "http://localhost:8202/ping",
207
+ "overseer-agent": "http://localhost:8203/ping",
208
+ }
209
+
210
+ for name, url in endpoints.items():
211
+ try:
212
+ resp = httpx.get(url, timeout=2)
213
+ if resp.status_code == 200:
214
+ console.print(f" [green]●[/green] {name} responding")
215
+ except Exception:
216
+ pass
217
+
218
+
219
+ if __name__ == "__main__":
220
+ app()
maple/config.py ADDED
@@ -0,0 +1,189 @@
1
+ """Configuration system for MAPLE."""
2
+
3
+ import importlib
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import yaml
9
+ from pydantic import BaseModel
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Models
14
+ # ---------------------------------------------------------------------------
15
+
16
+
17
+ class PostActionHook(BaseModel):
18
+ """A MADSci node action to call after each operator action."""
19
+
20
+ node: str
21
+ action: str
22
+ args: dict = {}
23
+
24
+
25
+ class ExperimentConfig(BaseModel):
26
+ """Experiment brief template."""
27
+
28
+ name: str = "Untitled Experiment"
29
+ objective: str = ""
30
+ constraints: list[str] = []
31
+
32
+
33
+ class OperatorConfig(BaseModel):
34
+ """Operator agent configuration."""
35
+
36
+ vision_backend: str = "" # "module:ClassName" or empty for StubBackend
37
+ custom_tools: list[str] = [] # ["module:function_name", ...] to register at startup
38
+ post_action_hooks: list[PostActionHook] = []
39
+ prompt: str = "" # Path to custom prompt file, or empty for default
40
+
41
+
42
+ class OverseerConfig(BaseModel):
43
+ """Overseer agent configuration."""
44
+
45
+ custom_tools: list[str] = [] # ["module:function_name", ...] to register at startup
46
+ prompt: str = "" # Path to custom prompt file, or empty for default
47
+
48
+
49
+ class MapleConfig(BaseModel):
50
+ """Root MAPLE configuration."""
51
+
52
+ experiment: ExperimentConfig = ExperimentConfig()
53
+ operator: OperatorConfig = OperatorConfig()
54
+ overseer: OverseerConfig = OverseerConfig()
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Loader
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ def load_config(path: Optional[Path] = None) -> MapleConfig:
63
+ """Load MAPLE configuration from a YAML file.
64
+
65
+ Args:
66
+ path: Path to config file. Defaults to maple.config.yaml in current directory.
67
+
68
+ Returns:
69
+ Validated MapleConfig instance.
70
+ """
71
+ path = path or Path("maple.config.yaml")
72
+ if not path.exists():
73
+ return MapleConfig()
74
+ data = yaml.safe_load(path.read_text())
75
+ return MapleConfig(**(data or {}))
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Dynamic Import
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ def import_string(dotted_path: str):
84
+ """Import a class or object from a 'module:ClassName' string.
85
+
86
+ Supports both colon-separated ('vision:BlockSortingVision') and
87
+ dot-separated ('vision.BlockSortingVision') formats.
88
+
89
+ If the module is a relative file path (e.g., 'vision' resolving to
90
+ './vision.py'), the current directory is added to sys.path temporarily.
91
+
92
+ Args:
93
+ dotted_path: Import path in 'module:Class' or 'module.Class' format.
94
+
95
+ Returns:
96
+ The imported class or object.
97
+ """
98
+ if ":" in dotted_path:
99
+ module_path, class_name = dotted_path.rsplit(":", 1)
100
+ else:
101
+ module_path, _, class_name = dotted_path.rpartition(".")
102
+
103
+ if not module_path or not class_name:
104
+ raise ImportError(f"Invalid import path: '{dotted_path}'. Use 'module:ClassName' format.")
105
+
106
+ # Support relative imports from CWD
107
+ cwd = str(Path.cwd())
108
+ if cwd not in sys.path:
109
+ sys.path.insert(0, cwd)
110
+
111
+ try:
112
+ module = importlib.import_module(module_path)
113
+ except ModuleNotFoundError as e:
114
+ raise ImportError(f"Cannot import module '{module_path}' from '{dotted_path}'") from e
115
+
116
+ try:
117
+ return getattr(module, class_name)
118
+ except AttributeError as e:
119
+ raise ImportError(f"Module '{module_path}' has no attribute '{class_name}'") from e
120
+
121
+
122
+ def load_vision_backend(config: MapleConfig):
123
+ """Load the VisionBackend class from config and instantiate it.
124
+
125
+ Args:
126
+ config: Loaded MapleConfig.
127
+
128
+ Returns:
129
+ A VisionBackend instance. StubBackend if no backend specified.
130
+ """
131
+ from maple.vision import StubBackend
132
+
133
+ backend_path = config.operator.vision_backend
134
+ if not backend_path:
135
+ return StubBackend()
136
+
137
+ cls = import_string(backend_path)
138
+ return cls()
139
+
140
+
141
+ def load_custom_tools(config: MapleConfig):
142
+ """Load and register custom MCP tools from config.
143
+
144
+ Each entry in config.operator.custom_tools should be a "module:function"
145
+ string. The function must already be decorated with @mcp.tool, OR it will
146
+ be registered programmatically.
147
+
148
+ Args:
149
+ config: Loaded MapleConfig.
150
+ """
151
+ if not config.operator.custom_tools:
152
+ return
153
+
154
+ from maple.operator.server import mcp
155
+
156
+ for tool_path in config.operator.custom_tools:
157
+ try:
158
+ func = import_string(tool_path)
159
+ # If not already registered, register it
160
+ if not hasattr(func, "__mcp_tool__"):
161
+ mcp.tool(func)
162
+ except ImportError as e:
163
+ raise ImportError(
164
+ f"Failed to load custom tool '{tool_path}': {e}. "
165
+ f"Check the path in maple.config.yaml under operator.custom_tools."
166
+ ) from e
167
+
168
+
169
+ def load_overseer_custom_tools(config: MapleConfig):
170
+ """Load and register custom MCP tools for the Overseer.
171
+
172
+ Args:
173
+ config: Loaded MapleConfig.
174
+ """
175
+ if not config.overseer.custom_tools:
176
+ return
177
+
178
+ from maple.overseer.server import mcp
179
+
180
+ for tool_path in config.overseer.custom_tools:
181
+ try:
182
+ func = import_string(tool_path)
183
+ if not hasattr(func, "__mcp_tool__"):
184
+ mcp.tool(func)
185
+ except ImportError as e:
186
+ raise ImportError(
187
+ f"Failed to load custom tool '{tool_path}': {e}. "
188
+ f"Check the path in maple.config.yaml under overseer.custom_tools."
189
+ ) from e
maple/hooks.py ADDED
@@ -0,0 +1,37 @@
1
+ """Agent event hooks for MAPLE provenance capture."""
2
+
3
+ import os
4
+ import requests
5
+
6
+
7
+ class ReasoningCaptureHook:
8
+ """Captures LLM reasoning text and logs to MADSci Event Manager.
9
+
10
+ Intercepts text tokens between tool calls, buffers them,
11
+ and flushes to the Event Manager when a tool is invoked.
12
+ """
13
+
14
+ def __init__(self, mcp_base_url: str = None):
15
+ self.mcp_base_url = mcp_base_url or os.getenv("MCP_EXECUTOR_URL", "http://localhost:8102")
16
+ self._buffer = ""
17
+
18
+ def accumulate(self, text: str):
19
+ """Add text to the reasoning buffer."""
20
+ self._buffer += text
21
+
22
+ def flush(self, tool_name: str):
23
+ """Flush buffered reasoning to the MCP server's log endpoint."""
24
+ if not self._buffer.strip():
25
+ return
26
+ try:
27
+ requests.post(
28
+ f"{self.mcp_base_url}/log_reasoning",
29
+ json={
30
+ "reasoning": self._buffer.strip(),
31
+ "before_tool": tool_name,
32
+ },
33
+ timeout=5,
34
+ )
35
+ except Exception:
36
+ pass
37
+ self._buffer = ""
maple/middleware.py ADDED
@@ -0,0 +1,42 @@
1
+ """MAPLE MCP middleware — extracts identity from Authorization header."""
2
+
3
+ from fastmcp.server.dependencies import get_http_headers
4
+ from fastmcp.server.middleware import Middleware, MiddlewareContext
5
+ from fastmcp.exceptions import ToolError
6
+
7
+ from maple.auth import is_valid_token
8
+
9
+
10
+ class AuthMiddleware(Middleware):
11
+ """Extracts bearer token from Authorization header and stashes in context.
12
+
13
+ Every tool call passes through this middleware. The token is made
14
+ available to tools via `await ctx.get_state("maple_identity")`.
15
+
16
+ Rejects requests with missing or malformed tokens.
17
+ """
18
+
19
+ async def on_call_tool(self, context: MiddlewareContext, call_next):
20
+ headers = get_http_headers(include={"authorization"})
21
+ authorization = headers.get("authorization", "")
22
+
23
+ scheme, _, token = authorization.partition(" ")
24
+ token = token.strip()
25
+
26
+ if scheme.lower() != "bearer" or not token:
27
+ raise ToolError(
28
+ "Authorization required. Include 'Authorization: Bearer <token>' header. "
29
+ "Run 'maple chat' to auto-generate credentials."
30
+ )
31
+
32
+ if not is_valid_token(token):
33
+ raise ToolError(
34
+ "Invalid token format. Expected a UUIDv4 string. "
35
+ "Delete ~/.maple/credentials and retry to regenerate."
36
+ )
37
+
38
+ if context.fastmcp_context is None:
39
+ raise ToolError("Request context unavailable")
40
+
41
+ await context.fastmcp_context.set_state("maple_identity", token)
42
+ return await call_next(context)