Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ path = "src/main.rs"
[dependencies]
anyhow = "1"
axum = "0.8"
base64 = "0.23"
form_urlencoded = "1.2"
getrandom = "0.4"
libc = "0.2"
clap = { version = "4", features = ["derive", "env"] }
qmacp = { git = "https://github.com/querymt/qmacp", tag = "v0.1.2" }
reqwest = { version = "0.13", default-features = false, features = ["json", "form", "rustls"] }
Expand All @@ -32,3 +36,4 @@ uuid = { version = "1", features = ["v4"] }
futures-util = "0.3"
jsonschema = { version = "0.60", default-features = false }
tokio-tungstenite = "0.29"
tower = { version = "0.5", features = ["util"] }
42 changes: 34 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,20 @@ cargo install --locked --path .

## Connect

For ChatGPT, qmcp provides embedded single-owner OAuth with explicit consent,
PKCE, dynamic client registration, and rotating refresh tokens. Run it on the
Mac behind an HTTPS reverse proxy; no external login service is needed:

See [How to connect](docs/how-to-connect.md) for local stdio, Bearer over HTTPS,
private tunnels, and OAuth/ChatGPT setup. The guide includes client examples,
authentication requirements, and remote-access precautions.
```sh
qmcp --transport http --permission cancel \
--oauth-local-resource https://mcp.example.com/mcp \
--oauth-owner-secret-file /absolute/private/path/to/owner-secret
```

See the [connection guide](docs/how-to-connect.md) for credential generation,
proxy configuration, stdio, static Bearer, tunnels, or external OAuth.
OAuth state is volatile: restart invalidates tokens and registered client IDs.
Approval grants all backend tools, not multi-user isolation.

## Tools

Expand All @@ -33,7 +43,7 @@ Start with `caps`, `profiles`, and `models` to discover available capabilities.
| Tools | Purpose |
| --- | --- |
| `caps`, `profiles`, `models` | Discover protocol features, profiles, and models |
| `new`, `sessions`, `find` | Create, list, and search sessions |
| `workspace`, `new`, `sessions`, `find` | Select a workspace without prompting; create, list, and search sessions |
| `status`, `inspect`, `runtime` | Read configuration, history, and live runtime |
| `get_lifecycle`, `set_lifecycle` | Read or explicitly patch resolved/archived state |
| `pin`, `unpin` | Set session pin state |
Expand All @@ -43,10 +53,22 @@ Start with `caps`, `profiles`, and `models` to discover available capabilities.
| `cancel`, `close` | Request turn cancellation or explicitly close a session |
| `exec` | Run a batch sequentially on one ACP connection |

Use `prompt` with `new: true` to start a session, then its returned `sessionId`
as `session_id` to continue. Pass an explicit `cwd` for workspace operations;
paths refer to the QueryMT host. `profiles`, `models`, lifecycle, and pin tools
return provider data under `result`, separate from bridge status and event metadata.
Choose a workspace once without prompting, then reuse the returned ID:

```text
workspace({"cwd":"/srv/projects/app","profile":"my-profile"})
prompt({"session_id":"<returned sessionId>","text":"Explain this project"})
workspace({"session_id":"<returned sessionId>","cwd":"../other"})
```

A directory switch creates a **new session**: use its returned `sessionId`.
The old ID remains unchanged; nothing implicitly closes or cancels its work.
`new` and `prompt(new=true)` also bind cwd; continuing `prompt` can omit cwd.
`status`, `inspect`, and `follow` reject directory changes. Unknown IDs require
backend `session/list` metadata; missing or incomplete metadata fails closed.
Paths are lexical POSIX paths on the QueryMT host, not a sandbox. Initial relative
paths use qmcp's startup cwd; later ones use the session cwd. `~` is rejected;
supply the backend's absolute home path. See [workspace details](docs/how-to-connect.md#select-a-session-workspace).

## Important Behavior

Expand All @@ -68,6 +90,10 @@ return provider data under `result`, separate from bridge status and event metad
- `find` scans at most 20 pages. Check `truncated` and `scanLimitReached` before
treating its results as exhaustive.

`inspect` still uses ACP `session/load`, which may restore runtime state; it is
not a guaranteed read-only history API. A backend replay failure is a tool error,
not empty history. No history endpoint or backend fix is supplied here.

## Development

```sh
Expand Down
147 changes: 141 additions & 6 deletions docs/how-to-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
| Setup | Best for | Authentication |
| --- | --- | --- |
| [Local stdio](#local-stdio) | Desktop and CLI MCP clients | Local process permissions |
| [Embedded OAuth on macOS](#embedded-oauth-on-macos-chatgpt) | Single-owner public ChatGPT connections | Built-in owner login + consent; no external provider |
| [Bearer over HTTPS](#bearer-over-https) | Clients with custom authorization headers | Shared token |
| [Private tunnel](#private-tunnel-chatgpt) | ChatGPT without a public endpoint | OpenAI tunnel access |
| [OAuth over HTTPS](#oauth-over-https-chatgpt) | Public authenticated ChatGPT connections | External OAuth provider |
| [External OAuth provider](#external-oauth-provider-optional) | Deployments with an existing identity provider | Optional external introspection |

Bearer and OAuth are HTTP authentication modes. The tunnel privately connects
ChatGPT to qmcp over stdio; it is not another MCP transport.
Expand All @@ -24,6 +25,30 @@ qmcp connects to `ws://127.0.0.1:3030/acp/ws` by default. Change it with `--url`
or `QMCP_ACP_URL`. Prefer `wss://` for remote ACP connections; plaintext
non-loopback ACP requires `--allow-insecure`. qmcp does not launch QueryMT.

## Select a Session Workspace

Call `workspace({"cwd":"/srv/projects/app","profile":"<optional profile ID>"})`
without sending a prompt. Reuse its returned `sessionId` in later calls; omit cwd
until you explicitly change it. There is no global active workspace.
`workspace({"session_id":"A","cwd":"../other"})` creates session B, leaving A
unchanged. Use B's returned ID. Continuing `prompt` with a different cwd behaves
likewise; `status`, `inspect`, and `follow` reject a different cwd.

Paths refer to the QueryMT host and normalize `.`/`..` lexically, without local
filesystem or symlink checks. Initial relative paths use qmcp's startup cwd;
subsequent paths use the bound session cwd. Supply absolute remote paths;
`~`, Windows paths, empty paths, and control characters are rejected.
Known profiles are inherited on switches; unknown ones use the backend default
and report `profileKnown:false`. Same-directory profile changes are rejected.

Bindings are immutable per ID, process-local, shared across HTTP calls, retained
on close, and capped at 4096 without eviction. Unknown IDs (including after
restart) require unfiltered paginated `session/list` metadata. Missing cwd,
repeated cursors, unavailable metadata, or the 20-page scan limit fail closed;
caller cwd cannot override them. Check `recovery` before retrying failed creation:
an acknowledged ID is retained, but an unacknowledged creation may still exist
remotely. Timeouts never implicitly cancel remote work.

## Local stdio

Configure your MCP client to launch qmcp:
Expand Down Expand Up @@ -92,9 +117,73 @@ to keep qmcp private. Your account/workspace must allow tunnels and custom MCP s
4. Select the plugin with `@` in a chat and ask: **"Use qmcp to list QueryMT profiles."**
Keep QueryMT and `tunnel-client` running. No public qmcp endpoint is needed.

## OAuth over HTTPS (ChatGPT)
## Embedded OAuth on macOS (ChatGPT)

qmcp runs the single-owner authorization server on the Mac. TLS terminates at
your trusted HTTPS proxy; the canonical public resource must end in `/mcp` with
no userinfo, query, or fragment. Its origin is the issuer.

Generate a credential only when preparing deployment (these commands do not
print it and refuse to overwrite an existing file):

```sh
umask 077
mkdir -p "$HOME/.config/qmcp"
chmod 700 "$HOME/.config/qmcp"
(set -C; openssl rand -hex 32 > "$HOME/.config/qmcp/owner-secret")
chmod 600 "$HOME/.config/qmcp/owner-secret"
```

Use only 32..64 cryptographically random bytes encoded as lowercase hex, never
a password. Keep the value in a trusted password manager for browser login, not
URLs, arguments, environment, logs, or source control. The absolute secret path
must be an owned Unix regular file, mode `0600`, without symlinks or hard links;
protect its parent directory too. qmcp neither generates nor prints the secret.

Leave `QMCP_BEARER_TOKEN` and `QMCP_OAUTH_CLIENT_SECRET` unset, then run:

```sh
qmcp --transport http --permission cancel \
--oauth-local-resource https://mcp.example.com/mcp \
--oauth-owner-secret-file "$HOME/.config/qmcp/owner-secret"
```

Use an external [OAuth provider](https://developers.openai.com/plugins/build/auth)
The default listener is `127.0.0.1:3031`. For a separate proxy, use the
[Nginx/VPN template](#separate-nginx-server-to-a-mac-over-vpn). Forward `/mcp`,
`/.well-known/oauth-authorization-server`, both protected-resource discovery
paths, and `/oauth/register`, `/oauth/authorize`, `/oauth/token`. Preserve Host,
Authorization, Origin, and queries. The public resource Host is automatically
allowed; local/external OAuth and static Bearer modes are mutually exclusive.

In ChatGPT, add the public `/mcp` URL, choose **OAuth**, and use DCR. Do not put
the owner credential in client ID/secret fields. During sign-in verify the domain,
unverified client name, and exact callback. Enter the credential on qmcp's owner
page and explicitly approve or deny, then click **Continue to ChatGPT**.
Registration alone grants nothing. Custom MCP servers must be enabled in your
workspace; live sign-in still needs deployment validation.

Security/lifetime constraints:

- Public clients use authorization-code + PKCE `S256`; DCR may additionally request
`refresh_token`. Exact client, redirect, resource, and scope are bound to grants.
Metadata URLs are never fetched. Login requires an issuer-Origin form POST,
a browser cookie, CSRF token, and explicit owner consent. Other routes reject Origin.
- Approval grants all tools on this backend (`qmcp:access`), not per-user isolation.
`--permission cancel` controls agent approvals, not MCP authentication.
- Access lasts up to 30 minutes; rotating one-use refresh tokens have an absolute
24-hour family deadline. Replay revokes the entire family. Serialize refreshes:
concurrent refreshes or retrying a lost successful response can revoke the winner.
- Credentials are stored as digests; state is bounded and volatile. Restart invalidates client IDs and tokens;
ChatGPT may require removing/re-adding the server. Approved clients survive until
restart; unapproved registrations expire after one hour. No persistence is supplied.
- Capacity/rate errors do not evict replay evidence or consume valid credentials.
A family allows at most 127 rotations; exhaustion requires owner authorization
again. Keep proxy connection/body/rate limits and private logs: application bounds
do not prevent all resource exhaustion. Never log OAuth queries, bodies, or tokens.

## External OAuth Provider (Optional)

For an existing identity provider, use an external [OAuth provider](https://developers.openai.com/plugins/build/auth)
with authorization-code + PKCE (`S256`) and ChatGPT client registration
(pre-registered, CIMD, or DCR). qmcp provides discovery and token introspection;
the provider handles login, consent, token issuance, and revocation.
Expand Down Expand Up @@ -136,13 +225,59 @@ per-user workspaces. Tokens are checked on every request without caching;
provider outages deny access. Live ChatGPT sign-in depends on your provider
configuration and has not been verified by the project's mock-provider tests.

## Separate Nginx server to a Mac over VPN

Topology: ChatGPT -> public HTTPS Nginx -> encrypted VPN -> qmcp on Mac:3031
-> local QueryMT. Replace the example domain, VPN address, and certificate paths.
Keep QueryMT's ACP port private. On the Mac, use the protected owner file above:

```sh
qmcp --transport http --bind 10.0.0.42:3031 --permission cancel \
--oauth-local-resource https://mcp.example.com/mcp \
--oauth-owner-secret-file /absolute/private/path/to/owner-secret
```

On the separate proxy host (not deployment instructions to execute during review):

```nginx
server {
listen 443 ssl;
server_name mcp.example.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
location / {
proxy_pass http://10.0.0.42:3031;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Authorization $http_authorization;
proxy_set_header Origin $http_origin;
proxy_set_header Connection "";
access_log off; # OAuth queries contain codes/state.
proxy_buffering off;
proxy_request_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
```

The upstream has no URI suffix, so discovery and authorization paths reach qmcp
unchanged. Never strip Origin to bypass validation. Firewall Mac port 3031 to the
proxy's VPN source only; do not expose it or ACP publicly. Plain HTTP assumes the
encrypted VPN hop, not an untrusted LAN. Keep the Mac awake and VPN reachable.
Validate adapted Nginx configuration before deployment. Source tests do not prove
real ChatGPT sign-in and must not restart installed services.

## Remote Access Checklist

- Use HTTPS: qmcp itself does not terminate TLS. Never send tokens over public plaintext HTTP.
- Keep qmcp's backend port private to the proxy with a firewall/private network.
- Forward Authorization and an allowed Host. Browser Origin headers are rejected.
- Non-loopback binds require Bearer or OAuth auth. Wildcard binds also require
explicit `--allowed-host` values (repeatable, optionally `host:port`).
- Forward Authorization, Host and Origin unchanged. MCP rejects browser Origin;
only embedded OAuth consent POSTs accept the exact public issuer Origin.
- Non-loopback binds require Bearer or OAuth auth. Wildcard binds require an
explicit Host allowlist (`--allowed-host`, repeatable, optionally `host:port`);
embedded OAuth supplies its canonical resource Host automatically.
- Use `--bind` only when the proxy needs another interface. Keep the default
loopback listener when the proxy runs on the same host.
- `--permission` controls QueryMT tool approval, not MCP client authentication.
Expand Down
Loading