diff --git a/README.md b/README.md index cdcfa9d..41d852b 100755 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ Each tool **provider** is a single YAML file under `tools/`. The YAML contains: 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 + bridge walks you through the OAuth flow and refreshes the token automatically. The web UI's + **Remote MCP Server** wizard option builds this for you from just the server URL. `server.py` loads every YAML at startup, installs declared `requirements` (pip packages), runs `setup_commands`, then registers each tool automatically — no Python files to @@ -828,10 +829,16 @@ endpoints**. The official Asana server is one: it lives at `https://mcp.asana.co (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: +a `package:` command. + +The easiest way to add one is the web UI's **+ New Provider → 🌐 Remote MCP Server** option: +paste the server URL (e.g. `https://mcp.asana.com/v2/mcp`) and mcpproxy builds the +`npx -y mcp-remote ` command, introspects the tool list, and walks you through the OAuth +flow. The equivalent YAML it produces (which you can also write by hand) is: ```yaml -# examples/asana.yaml — copy into your tools/ config dir (or use the wizard) +# Paste into your tools/ config dir, or use the wizard's "Remote MCP Server" +# option (just paste the URL — it builds this command for you). package: command: npx -y mcp-remote https://mcp.asana.com/v2/mcp @@ -846,8 +853,8 @@ 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 + server in the **+ New Provider → Remote MCP Server** (or **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. diff --git a/examples/asana.yaml b/examples/asana.yaml deleted file mode 100644 index 161d2ee..0000000 --- a/examples/asana.yaml +++ /dev/null @@ -1,47 +0,0 @@ -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: {} diff --git a/frontend/app.py b/frontend/app.py index a12450f..ede1226 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -1142,25 +1142,25 @@ async def index():

How do you want to create this provider?

-
-
+
+
-
🐍
-
Python Code
- Write async def functions — each one becomes an MCP tool +
🌐
+
Remote MCP Server
+ Bridge a remote, OAuth-protected MCP server — just paste its URL (e.g. the official Asana server). Tools & auth are handled automatically.
-
+
📦
Package
- Run an existing MCP server via npx, uvx, python -m, or any command — tools are auto-detected + Run an existing MCP server via npx, uvx, python -m, or any command — or bridge a remote server with npx -y mcp-remote <url>. Tools are auto-detected.
-
+
📂
@@ -1169,7 +1169,32 @@ async def index():
+
+
+
+
🐍
+
Python Code
+ Write async def functions — each one becomes an MCP tool +
+
+
+
+
+ + +
+
+ +
+
+ + +
The remote MCP server endpoint. mcpproxy bridges it with npx -y mcp-remote <url> — transport is auto-detected.
+
+
When you click Next the server is introspected automatically; its tools become the dropdown options in the editor. If the server is OAuth-protected, a clickable 🔐 Authorize link appears — complete the browser flow and introspection continues. The token is cached and refreshed automatically afterwards.
+
@@ -1182,7 +1207,7 @@ async def index(): -
Any command that spawns a stdio MCP server (npx, uvx, python -m, or an installed binary).
+
Any command that spawns a stdio MCP server (npx, uvx, python -m, or an installed binary). To bridge a remote, OAuth-protected MCP server, use npx -y mcp-remote <url> (or pick the Remote MCP Server option, which builds this for you).
@@ -1290,7 +1315,7 @@ async def index(): let currentProvider = null; // the structured JSON object being edited let codeEditor = null; // CodeMirror instance for the code block let secretsModal = null, wizModal = null; -let wzType = null; // 'code' | 'package' | 'repository' +let wzType = null; // 'code' | 'package' | 'repository' | 'remote' let wzStep = 'type'; let wzIntrospectedTools = []; // tools returned by introspect let wzRepoCtx = null; // repository-wizard state carried across steps @@ -2076,7 +2101,7 @@ async def index(): // ───────────────────────────────────────────────────────────────────────────── // Wizard // ───────────────────────────────────────────────────────────────────────────── -const WZ_STEPS = ['type','package','repository','code','secrets']; +const WZ_STEPS = ['type','remote','package','repository','code','secrets']; function wzShowStep(step) { WZ_STEPS.forEach(s => { @@ -2107,6 +2132,9 @@ async def index(): document.getElementById('wz-repo-cmd').value = ''; document.getElementById('wz-repo-builds-container').innerHTML = ''; document.getElementById('wz-repo-result').innerHTML = ''; + document.getElementById('wz-remote-name').value = ''; + document.getElementById('wz-remote-url').value = ''; + document.getElementById('wz-remote-result').innerHTML = ''; wzShowStep('type'); wizModal.show(); } @@ -2162,6 +2190,48 @@ async def index(): if (wzStep === 'type') return; + if (wzStep === 'remote') { + const name = document.getElementById('wz-remote-name').value.trim(); + const url = document.getElementById('wz-remote-url').value.trim(); + if (!name) { errEl.textContent = 'Provider name is required.'; return; } + if (!/^https?:\/\//i.test(url)) { errEl.textContent = 'A server URL starting with http:// or https:// is required.'; return; } + // Bridge the remote server through mcp-remote, exactly like the Asana + // example. The OAuth/token flow is handled by the shared introspect helper. + const cmd = 'npx -y mcp-remote ' + url; + const nextBtn = document.getElementById('wz-next-btn'); + nextBtn.disabled = true; + const origText = nextBtn.textContent; + nextBtn.textContent = '⏳ Introspecting…'; + try { + await _wzIntrospectCommand(cmd, {resultEl: document.getElementById('wz-remote-result')}); + } finally { + nextBtn.disabled = false; + nextBtn.textContent = origText; + } + const provider = { + name, type: 'package', command: cmd, + documentation: 'Remote MCP server bridged via `mcp-remote` (' + url + '). ' + + 'Authentication (OAuth/token) is handled by mcp-remote and refreshed automatically.', + code: '', requirements: [], setup_commands: [], + tools: wzIntrospectedTools.map(t => ({ + name: t.name, + function: '', + description: t.description || '', + documentation: '', + enabled: true, + parameters: _schemaToParams(t.inputSchema || t.input_schema || {}), + secrets: [], + })), + }; + try { + const r = await api('POST', '/api/tools', {name, provider}); + currentName = name; currentProvider = provider; + loadList(); + await wzGoSecrets(r.secret_keys || []); + } catch(e) { errEl.textContent = e.message; } + return; + } + if (wzStep === 'package') { const name = document.getElementById('wz-pkg-name').value.trim(); const cmd = document.getElementById('wz-pkg-cmd').value.trim(); @@ -2300,7 +2370,7 @@ async def index(): } function wzBack() { - const map = {package:'type', repository:'type', code:'type', secrets: wzType||'type'}; + const map = {remote:'type', package:'type', repository:'type', code:'type', secrets: wzType||'type'}; wzShowStep(map[wzStep] || 'type'); } @@ -2314,6 +2384,15 @@ async def index(): } const requirements = _wzGetListValues('wz-pkg-reqs-container'); const setup_commands = _wzGetListValues('wz-pkg-cmds-container'); + await _wzIntrospectCommand(cmd, {requirements, setup_commands, resultEl: el}); +} + +// Shared introspection routine used by both the Package and Remote steps. +// Polls /api/pending-auth so OAuth-protected servers (bridged via mcp-remote) +// surface a clickable "Authorize" link while the handshake is blocked, then +// runs /api/introspect and stores the result in wzIntrospectedTools. +async function _wzIntrospectCommand(cmd, {requirements = [], setup_commands = [], resultEl} = {}) { + const el = resultEl; el.innerHTML = 'Introspecting — this may take a moment on first use…'; // Remote OAuth servers (bridged via mcp-remote) block introspection until the // user authorizes in a browser. Poll for the authorization URL meanwhile and diff --git a/tests/test_frontend.py b/tests/test_frontend.py index b71da06..d69fe04 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -211,6 +211,18 @@ def test_package_yaml_uses_package_key(self, client, tools_dir): assert "package" in spec assert "npx" not in spec + def test_remote_provider_saved_as_package(self, client, tools_dir): + """The wizard's Remote MCP Server option produces a package provider + whose YAML bridges the URL via mcp-remote (matching the Asana example).""" + provider = { + **PACKAGE_PROVIDER, + "command": "npx -y mcp-remote https://mcp.asana.com/v2/mcp", + } + client.post("/api/tools", json={"name": "asana", "provider": provider}) + spec = yaml.safe_load((tools_dir / "asana.yaml").read_text()) + assert "package" in spec + assert spec["package"]["command"] == "npx -y mcp-remote https://mcp.asana.com/v2/mcp" + def test_requirements_saved_to_yaml(self, client, tools_dir): provider = {**CODE_PROVIDER, "requirements": ["httpx"]} client.post("/api/tools", json={"name": "myprovider", "provider": provider})