Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,8 @@ tools/
files/
repos/

# OAuth token cache for mcp-remote bridges (contains live access/refresh tokens)
.mcp-auth/

# Legacy Playwright MCP output directory (replaced by ./files in docker-compose)
.playwright-mcp/
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ Each tool **provider** is a single YAML file under `tools/`. The YAML contains:
via `npx`, `uvx`, `python -m`, or any installed binary
- Or a `package:` + `repository:` pair to clone a git repo, run build commands, and
spawn the resulting stdio MCP server — useful for servers distributed only as source
- Or a `package:` block running the [`mcp-remote`](https://www.npmjs.com/package/mcp-remote)
bridge to reach a **remote, OAuth-protected** server (e.g. the official Asana MCP) — the
bridge walks you through the OAuth flow and refreshes the token automatically

`server.py` loads every YAML at startup, installs declared `requirements` (pip packages),
runs `setup_commands`, then registers each tool automatically — no Python files to
Expand Down Expand Up @@ -777,6 +780,65 @@ call to it. The process is reused across calls (started lazily on the first tool

---

### Part 3.25 — a remote, OAuth-protected server (e.g. the official Asana MCP)

Some MCP servers aren't stdio packages at all — they're **remote, OAuth-protected HTTP
endpoints**. The official Asana server is one: it lives at `https://mcp.asana.com/v2/mcp`
(Streamable HTTP) and is reached through an OAuth 2.1 authorization-code (PKCE) flow — there's
no static API key. mcpproxy speaks stdio to its upstreams, so these are bridged with the
community [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) adapter, which is itself just
a `package:` command:

```yaml
# examples/asana.yaml — copy into your tools/ config dir (or use the wizard)
package:
command: npx -y mcp-remote https://mcp.asana.com/v2/mcp

tools:
- name: get_me # advertised as asana__get_me; the rest are auto-introspected
description: Return the Asana user that the authorized token belongs to.
input_schema: { type: object, properties: {} }
```

**`mcp-remote` performs the OAuth walkthrough and refreshes the token for you** — mcpproxy
itself stays a thin stdio proxy:

- **First run** (or after the refresh token expires / is revoked): `mcp-remote` prints an
authorization URL and blocks the MCP handshake until you authorize. When you introspect the
command in the **+ New Provider → Package** wizard, mcpproxy scrapes that URL from stderr and
shows a clickable **🔐 Authorize** link (it's also logged as
`authorization required … visit:`). Open it, approve access in Asana, and the localhost
callback (`:3334`) completes the flow — introspection then continues automatically and the
tool list populates.
- **Afterwards** the OAuth token cache is written under `MCP_REMOTE_CONFIG_DIR` and **the access
token is refreshed silently** on every expiry. You don't authorize again until the refresh
token itself lapses.

#### Persisting the token cache (so you authorize once)

`docker-compose.yml` wires this up: it sets `MCP_REMOTE_CONFIG_DIR=/app/.mcp-auth`, mounts the
`mcpproxy-mcp-auth` volume there (kept **out** of `/app/files` so tokens are never exposed via
`mcpproxy__getfile`), and maps the OAuth callback port `3334`. Keep that volume and the refresh
token survives restarts. In dev, `docker-compose.override.yml` bind-mounts `./.mcp-auth`
(gitignored).

#### Headless / one-time bootstrap

The OAuth redirect targets `localhost:3334`. Either authorize via the wizard link with port
`3334` mapped (the default), **or** run the flow once on the host to pre-populate the cache,
then start the proxy with the same dir mounted:

```bash
MCP_REMOTE_CONFIG_DIR=./.mcp-auth npx -y mcp-remote https://mcp.asana.com/v2/mcp
# authorize in the browser, then `docker compose up` — tools work with no further prompts
```

> **Pin the bridge** for reproducible builds once you've settled on a version, e.g.
> `npx -y mcp-remote@<version> …`. Add `--debug` to write a detailed auth/refresh log under
> `MCP_REMOTE_CONFIG_DIR`.

---

### Part 3.5 — a repository provider (clone + build + introspect)

For MCP servers that are published only as source code (no `npx` / `uvx` / pip distribution),
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.override.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ services:
- ./tools:/app/tools
- ./files:/app/files
- ./repos:/app/repos
# OAuth token cache for mcp-remote bridges (Asana, …). Bind-mounted so the
# refresh token survives `docker compose down` and you authorize only once.
# Gitignored — never commit it (it contains live OAuth tokens).
- ./.mcp-auth:/app/.mcp-auth
- ./.env:/app/.env
14 changes: 14 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
# Playwright browser binaries, …)
# mcpproxy-npm /root/.npm — npm/npx package cache
# mcpproxy-uv-tools /root/.local/share/uv — uvx per-tool venvs
# mcpproxy-mcp-auth /app/.mcp-auth — OAuth token cache for mcp-remote
# bridges (e.g. the Asana provider).
# PERSIST THIS: it holds the refresh
# token, so you only authorize once.
# Kept out of /app/files so tokens are
# never exposed via mcpproxy__getfile.
#
# Every cache/artefact volume is optional in spirit — remove the entry and the
# container falls back to ephemeral storage on its writable layer (re-clones,
Expand All @@ -32,20 +38,27 @@ services:
ports:
- "${MCP_HOST_PORT:-8888}:8888"
- "${UI_HOST_PORT:-8889}:8889"
# OAuth callback for mcp-remote bridges (e.g. Asana). Only needed while
# authorizing in a browser the first time; harmless to leave mapped.
- "${MCP_OAUTH_CALLBACK_PORT:-3334}:3334"
env_file:
- ./.env
environment:
MCP_TOOL_CONFIG_DIR: "/app/tools"
MCP_ENV_FILE: "/app/.env"
MCPPROXY_FILES_DIR: "/app/files"
MCPPROXY_REPOS_DIR: "/app/repos"
# Where mcp-remote caches OAuth tokens (access + refresh). Persisted via
# the mcpproxy-mcp-auth volume so you authorize once and refresh silently.
MCP_REMOTE_CONFIG_DIR: "/app/.mcp-auth"
volumes:
- mcpproxy-tools:/app/tools
- mcpproxy-files:/app/files
- mcpproxy-repos:/app/repos
- mcpproxy-cache:/root/.cache
- mcpproxy-npm:/root/.npm
- mcpproxy-uv-tools:/root/.local/share/uv
- mcpproxy-mcp-auth:/app/.mcp-auth
- ./.env:/app/.env

volumes:
Expand All @@ -55,3 +68,4 @@ volumes:
mcpproxy-cache:
mcpproxy-npm:
mcpproxy-uv-tools:
mcpproxy-mcp-auth:
47 changes: 47 additions & 0 deletions examples/asana.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
documentation: |
# Asana (official remote MCP, OAuth)

Bridges the official Asana MCP server — a **remote, OAuth-protected** server at
`https://mcp.asana.com/v2/mcp` (Streamable HTTP) — into mcpproxy via the
community [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) stdio bridge.
There is no static API key: access is granted through an OAuth 2.1
authorization-code (PKCE) flow.

## How auth works
- **First run (or after the refresh token expires / is revoked):** `mcp-remote`
prints an authorization URL to stderr and blocks the MCP handshake until you
authorize. The provider wizard surfaces this as a clickable **Authorize**
link (it also appears in the server log: `authorization required … visit:`).
Open it, approve access in Asana, and the localhost callback completes the
flow automatically.
- **Afterwards (steady state):** the OAuth token cache is written under
`MCP_REMOTE_CONFIG_DIR` (mounted as a persistent volume — see
docker-compose.yml). `mcp-remote` then **refreshes the access token silently**
on its own. No further interaction is needed across restarts until the
refresh token itself expires.

## One-time bootstrap (headless / Docker)
The OAuth callback listens on `localhost:3334`. Either:
1. Map port `3334` out of the container (done in docker-compose.yml) and click
the Authorize link from the wizard, **or**
2. Run the flow once with host networking to populate the token cache, e.g.
`MCP_REMOTE_CONFIG_DIR=./files/.mcp-auth npx -y mcp-remote https://mcp.asana.com/v2/mcp`,
then start the proxy with that same volume mounted.

## Tools
The full Asana tool set (tasks, projects, search, comments, workspaces, …) is
auto-populated when the wizard introspects the command after you authorize.
The single declaration below (`get_me`) is a safe, zero-argument starter you
can call to confirm the connection.

package:
command: npx -y mcp-remote https://mcp.asana.com/v2/mcp

tools:
# Auto-populated by the wizard's introspection step once authorized.
# `get_me` returns the authenticated user — handy for verifying the connection.
- name: get_me # advertised to the LLM as asana__get_me
description: Return the Asana user that the authorized token belongs to.
input_schema:
type: object
properties: {}
33 changes: 33 additions & 0 deletions frontend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,24 @@ async def introspect_package(request: Request) -> dict:
traceback.print_exc()
return {"ok": False, "error": str(exc), "tools": [], "package_manager": pm}

@app.get("/api/pending-auth")
async def pending_auth(command: str = "") -> dict:
"""Return the OAuth authorization URL a spawn is currently waiting on.

Remote OAuth-protected MCP servers reached via the `mcp-remote` bridge
print an authorization URL to stderr and block the MCP handshake until
the user authorizes in a browser. `process_runner` scrapes that URL;
the wizard polls this endpoint (while an introspect call is blocked) to
show a clickable "Authorize" link. Once a valid token cache exists the
bridge refreshes silently and this stays empty.

With no `command`, returns every pending URL keyed by spawn command.
"""
from process_runner import pending_auth_urls
if command:
return {"ok": True, "auth_url": pending_auth_urls.get(command.strip())}
return {"ok": True, "pending": dict(pending_auth_urls)}

# ── Repository clone-and-build ───────────────────────────────────────────

@app.post("/api/clone-and-build")
Expand Down Expand Up @@ -2297,6 +2315,19 @@ async def index():
const requirements = _wzGetListValues('wz-pkg-reqs-container');
const setup_commands = _wzGetListValues('wz-pkg-cmds-container');
el.innerHTML = '<span class="text-muted" style="font-size:.875em">Introspecting — this may take a moment on first use…</span>';
// Remote OAuth servers (bridged via mcp-remote) block introspection until the
// user authorizes in a browser. Poll for the authorization URL meanwhile and
// surface it as a clickable link so the wizard can walk the user through it.
const authPoll = setInterval(async () => {
try {
const a = await api('GET', '/api/pending-auth?command=' + encodeURIComponent(cmd));
if (a && a.auth_url) {
el.innerHTML = `<div class="text-warning" style="font-size:.875em">🔐 Authorization required — `
+ `<a href="${esc(a.auth_url)}" target="_blank" rel="noopener">click here to authorize</a>, `
+ `then complete the browser flow. Introspection continues automatically once you finish.</div>`;
}
} catch {}
}, 1500);
try {
const r = await api('POST', '/api/introspect', {command: cmd, requirements, setup_commands});
if (!r.ok) throw new Error(r.error || 'Introspection failed');
Expand All @@ -2309,6 +2340,8 @@ async def index():
} catch(e) {
wzIntrospectedTools = [];
el.innerHTML = `<div class="text-warning" style="font-size:.875em">⚠ Introspection failed (${esc(e.message)}). Continuing without auto-detected tools — add them manually in the editor.</div>`;
} finally {
clearInterval(authPoll);
}
}

Expand Down
Loading
Loading