A local project runtime for AI.
Connect MCP-compatible AI clients to the folder selected on your Mac. Luti provides local tools and clear permission boundaries.
Luti connects an AI client to one approved project. The AI host handles reasoning and orchestration. Luti runs tools locally and enforces the project boundary and permissions. It can read and edit files, run builds and tests, inspect Git, drive a browser, export artifacts, and maintain project memory across AI clients. All of these operations run on your Mac.
- Install Luti. Download the signed DMG from the latest GitHub Release, or build it from source.
- Add a folder. Choose one folder in Projects. It starts in Safe Mode; switch on Full Access on the project page to skip approvals. Removing it returns the page to its empty state.
- Configure a connection. In Connections, configure OpenAI Secure MCP Tunnel or Cloudflare Tunnel (either or both may be enabled).
- Start and connect. Start the local Runtime, then add the MCP server in your AI client.
Upgrading to 1.4.0: Refresh Luti's tool list in each connected AI client, or remove and re-add the connector. Existing projects open in Safe Mode; turn on Full Access on the project page to keep the previous behavior. Data from 1.0.x is not migrated.
| Connection | What you provide | Authentication | Best for |
|---|---|---|---|
| OpenAI Secure MCP Tunnel | Tunnel ID + runtime API key | ChatGPT workspace association | ChatGPT, with no public endpoint |
| Cloudflare Tunnel | HTTPS domain + Tunnel Token | OAuth approved via Luti popup on this Mac | Custom MCP clients using a stable public URL |
OpenAI uses its private, credential-bound loopback listener and outbound tunnel-client; Cloudflare adds an independent OAuth-protected listener for a named HTTPS domain. Either connection can start the runtime, and both may run together. Failed transports reconnect without restarting project jobs. Stop the runtime before changing connection settings; stopping also ends jobs and browser sessions. See Cloudflare setup and authorization.
- Create a Secure MCP Tunnel: In OpenAI Platform Tunnel settings, create a Tunnel and associate the ChatGPT workspace that will use it (requires
Tunnels Read + Manage). - Create a runtime API key: In OpenAI API Keys, create an API key for
tunnel-client(requiresTunnels Read + Use). - Finish setup in Luti: Enter the Tunnel ID and Runtime API Key and save. Luti connects automatically while running.
- Add it in ChatGPT: In ChatGPT Plugins, select + > Add custom MCP server, choose Tunnel under Connection, and select your Tunnel ID while Luti is connected.
- Create a Cloudflare Tunnel: In the Cloudflare Tunnel dashboard, create or select a remotely managed Cloudflared tunnel and copy its Tunnel Token.
- Route your public hostname: Add a Public Hostname for your HTTPS domain (e.g.
https://mcp.example.com) and route the service URL tohttp://127.0.0.1:39393. - Finish setup in Luti: Enter the HTTPS domain and Tunnel Token and save. When enabled, Luti connects automatically while running.
- Add in your AI client and approve: Add
https://<your-domain>/mcpas a custom MCP server with OAuth, verify the browser code, and click Allow access in the Luti approval dialog on this Mac.
For sandboxed AI agents or scripts without a public callback backend: the client generates the authorization URL → the user opens it in a browser and approves the popup in Luti → the user pastes the final redirected callback URL back to the client → the client exchanges code for tokens and calls /mcp.
- Discovery & Dynamic Client Registration (DCR)
- Discovery:
GET /.well-known/oauth-authorization-serverandGET /.well-known/oauth-protected-resource/mcp - Register:
POST /oauth/registerwith JSON body{"client_name": "...", "redirect_uris": ["https://..."], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "scope": "mcp", "token_endpoint_auth_method": "none"}. Save the returnedclient_id(luti_prefix). Sandboxed clients may register a validhttps://placeholder callback URI and copy the redirected URL manually.
- Discovery:
- Generate PKCE Authorization URL & Approve
- Generate and persist
verifier,S256code_challenge, and randomstate, then build/oauth/authorize?response_type=code&client_id=...&redirect_uri=...&scope=mcp&state=...&code_challenge=...&code_challenge_method=S256&resource=https://<your-domain>/mcp. - Send the authorization URL as plain text / code block (never as a rich link preview card, as chat link prefetchers will hit
/oauth/authorizeprematurely). After approving in Luti on the Mac, paste back the full redirected URL (?code=...&state=...).
- Generate and persist
- Exchange & Rotate Tokens (Key Rules)
- One-time refresh rotation & replay guard:
access_tokenis an opaque token valid for 3600 seconds (cache and reuse it; do not refresh on every call).refresh_tokenis single-use rotating: each refresh invalidates the previousrefresh_tokenimmediately, so persist the new pair with0600permissions right away. Re-submitting a spentrefresh_tokentriggers replay protection and revokes the entire grant (do not blindly retry with the oldrefresh_tokenafter a network timeout). - Call
/mcp: Sendinitialize+notifications/initialized, thentools/list/tools/call(mutating tools requireprojectTokenfromproject_info).
- One-time refresh rotation & replay guard:
import base64, hashlib, json, secrets, urllib.parse, urllib.request
BASE, UA = "https://mcp.example.com", "Mozilla/5.0"
RES, CID, REDIRECT = f"{BASE}/mcp", "<client_id>", "<registered_redirect_uri>"
def build_auth_url():
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
state = secrets.token_urlsafe(16)
qs = urllib.parse.urlencode({
"response_type": "code", "client_id": CID, "redirect_uri": REDIRECT,
"scope": "mcp", "state": state, "code_challenge": challenge,
"code_challenge_method": "S256", "resource": RES,
})
return f"{BASE}/oauth/authorize?{qs}", verifier, state
def exchange_or_refresh(form):
data = urllib.parse.urlencode({"client_id": CID, "resource": RES, **form}).encode()
req = urllib.request.Request(f"{BASE}/oauth/token", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded", "User-Agent": UA})
return json.load(urllib.request.urlopen(req, timeout=25)) # Persist new access_token & refresh_token immediately
def mcp_call(access_token, method, params=None, mid=1):
body = json.dumps({"jsonrpc": "2.0", "id": mid, "method": method, "params": params or {}}).encode()
req = urllib.request.Request(RES, data=body, headers={
"Content-Type": "application/json", "Accept": "application/json, text/event-stream",
"Authorization": f"Bearer {access_token}", "User-Agent": UA})
raw = urllib.request.urlopen(req, timeout=30).read().decode()
for line in raw.splitlines():
if line.startswith("data:"):
return json.loads(line[5:])
return json.loads(raw)Luti exposes 24 public MCP tools across seven domains. MCP uses the folder selected locally, with no projects or skills tool.
| Domain | Public tools | Scope |
|---|---|---|
| Memory | memory_query, memory_action |
Project memory shared across AI clients: read-only index/read; write/delete guarded by the file's sha256, so writes never overwrite blindly; kind=task checklists hand unfinished work to the next session |
| Project | project_info, inspect_project, runtime_status |
Current workspace, discovery, runtime state |
| Files & Code | list_directory, read_files, read_image, search_project, edit_files, path_action, code_query |
Search, read, edit, move, structured code queries |
| Process & Jobs | run_process, run_shell, job_query, job_action |
Builds, tests, long jobs, logs, PTY input, stop |
| Git | git_query |
Status, diff, log, show, blame; read-only |
| Browser | browser_session, browser_observe, browser_action, browser_transfer, browser_evaluate |
Navigation, actions, screenshots, transfers, console, network, dialogs |
| Artifacts | export_artifact, import_artifact |
Immutable exports and controlled imports |
Project-specific behavior comes from the project's own manifest, instructions, and tasks rather than an expanding public tool set.
Start with project_info for the current workspace, projectToken, memory files with task progress and the latest changes from any AI client. Project writes, memory mutations (memory_action), command execution, browser mutations and Job input require this token. It changes after selecting a different folder locally or restarting the Runtime; re-observe the intended workspace after a mismatch. Stopping an owned Job remains available without a current token.
code_query starts an installed language server and requires the current projectToken; the server may have side effects.
Job results separate process completion from recognized test results and record a bounded set of observed input files. reportPath can name a JUnit XML report produced by the command. Unchanged reports, incomplete output and unproven input consistency remain unknown; changed inputs are marked stale. These observations do not prove the entire project is correct.
import_artifact saves an unexpired artifact from the current project runtime to a new project file, up to 32 MiB. It does not overwrite, execute or unpack files. Chat attachments are outside the current scope; evaluate a Host adapter only when a concrete Host exposes a verified file binding.
inspect_project returns a compact task registry and scoped instruction sources. Selecting a folder and pressing Start enables full local execution directly.
Luti stores per-project memory (MEMORY.md index and topic .md decision files) and bounded execution activity in ~/.luti/. Current-run changed paths and job validation stay in memory. It does not persist session journals or file recovery data, and does not write runtime metadata into the project repository.
| Boundary | Behavior |
|---|---|
| Project scope | File and project operations stay inside the active approved project |
| Permission mode | Safe Mode (default) asks on this Mac before deleting files, mutating Git, or running shell/inline code; Full Access runs them without asking. External operations retain their permission boundaries in both modes |
| Secrets | Runtime credentials use macOS Keychain; sensitive values are redacted before persistence |
| Remote access | Separate loopback listeners: OpenAI uses a private transport credential; Cloudflare requires domain-bound OAuth approved on this Mac |
| Git | Public Git capability is read-only |
| Browser automation | Luti owns its browser sessions; desktop capture and input are unavailable |
| Process execution | fullLocal runs with your user privileges and is not an OS sandbox |
Luti has no cloud backend that receives project data. The AI hosts you connect still receive the tool results you request, which may include source code, command output, screenshots, or other project data. Every AI host and tunnel provider you enable is part of your trust boundary.
- macOS 14 or later.
- An Apple Silicon Mac (arm64).
- A separately installed Google Chrome for browser automation with the pinned browser runtime.
- Xcode 26+ recommended for source builds.
- An OpenAI account with a configured Tunnel and runtime API key.
The app includes English, Simplified Chinese, and Japanese.
git clone https://github.com/boltguo/Luti.git
cd Luti
open Luti.xcodeprojThe main schemes are Luti and LutiProcessHost. The app target signs with Apple Development (automatic); set your own Team under Signing & Capabilities, or choose Sign to Run Locally. Production releases keep Hardened Runtime enabled and ship as signed, notarized DMGs. Sparkle checks for signed updates but does not install them silently.
Luti is licensed under the Apache License 2.0. Third-party and bundled runtime notices are listed in THIRD_PARTY_NOTICES.
Read CONTRIBUTING.md before proposing a change. Follow SECURITY.md to report a vulnerability privately.
If Luti is useful to you, you can support development on Buy Me a Coffee.
