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
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <url>` 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

Expand All @@ -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.
Expand Down
47 changes: 0 additions & 47 deletions examples/asana.yaml

This file was deleted.

103 changes: 91 additions & 12 deletions frontend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1142,25 +1142,25 @@ async def index():
<div id="wz-type" class="wizard-step active">
<p class="text-muted mb-4">How do you want to create this provider?</p>
<div class="row g-3">
<div class="col-md-4">
<div class="card wizard-choice h-100" onclick="wzSelectType('code')">
<div class="col-md-3">
<div class="card wizard-choice h-100" onclick="wzSelectType('remote')">
<div class="card-body text-center p-4">
<div style="font-size:2.5em">🐍</div>
<h6 class="mt-2">Python Code</h6>
<small class="text-muted">Write <code>async def</code> functions — each one becomes an MCP tool</small>
<div style="font-size:2.5em">🌐</div>
<h6 class="mt-2">Remote MCP Server</h6>
<small class="text-muted">Bridge a remote, OAuth-protected MCP server — just paste its URL (e.g. the official Asana server). Tools &amp; auth are handled automatically.</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="col-md-3">
<div class="card wizard-choice h-100" onclick="wzSelectType('package')">
<div class="card-body text-center p-4">
<div style="font-size:2.5em">📦</div>
<h6 class="mt-2">Package</h6>
<small class="text-muted">Run an existing MCP server via <code>npx</code>, <code>uvx</code>, <code>python -m</code>, or any command — tools are auto-detected</small>
<small class="text-muted">Run an existing MCP server via <code>npx</code>, <code>uvx</code>, <code>python -m</code>, or any command — or bridge a remote server with <code>npx -y mcp-remote &lt;url&gt;</code>. Tools are auto-detected.</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="col-md-3">
<div class="card wizard-choice h-100" onclick="wzSelectType('repository')">
<div class="card-body text-center p-4">
<div style="font-size:2.5em">📂</div>
Expand All @@ -1169,7 +1169,32 @@ async def index():
</div>
</div>
</div>
<div class="col-md-3">
<div class="card wizard-choice h-100" onclick="wzSelectType('code')">
<div class="card-body text-center p-4">
<div style="font-size:2.5em">🐍</div>
<h6 class="mt-2">Python Code</h6>
<small class="text-muted">Write <code>async def</code> functions — each one becomes an MCP tool</small>
</div>
</div>
</div>
</div>
</div>

<!-- Step: remote MCP server (URL → mcp-remote bridge) -->
<div id="wz-remote" class="wizard-step">
<div class="mb-3">
<label class="form-label">Provider name</label>
<input class="form-control" id="wz-remote-name" placeholder="asana">
</div>
<div class="mb-3">
<label class="form-label">Server URL *</label>
<input class="form-control font-monospace" id="wz-remote-url"
placeholder="https://mcp.asana.com/v2/mcp">
<div class="text-muted mt-1" style="font-size:.8em">The remote MCP server endpoint. mcpproxy bridges it with <code>npx -y mcp-remote &lt;url&gt;</code> — transport is auto-detected.</div>
</div>
<div class="text-muted" style="font-size:.8em">When you click <b>Next</b> the server is introspected automatically; its tools become the dropdown options in the editor. If the server is OAuth-protected, a clickable <b>🔐 Authorize</b> link appears — complete the browser flow and introspection continues. The token is cached and refreshed automatically afterwards.</div>
<div id="wz-remote-result" class="mt-2"></div>
</div>

<!-- Step: package command -->
Expand All @@ -1182,7 +1207,7 @@ async def index():
<label class="form-label">Command *</label>
<input class="form-control font-monospace" id="wz-pkg-cmd"
placeholder="npx @playwright/mcp@latest · uvx mcp-server-fetch · python -m mcp_server_github">
<div class="text-muted mt-1" style="font-size:.8em">Any command that spawns a stdio MCP server (npx, uvx, python -m, or an installed binary).</div>
<div class="text-muted mt-1" style="font-size:.8em">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 <code>npx -y mcp-remote &lt;url&gt;</code> (or pick the <b>Remote MCP Server</b> option, which builds this for you).</div>
</div>
<div class="mb-3">
<label class="form-label">pip Requirements <span class="text-muted fw-normal" style="text-transform:none">optional</span></label>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
}

Expand All @@ -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 = '<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
Expand Down
12 changes: 12 additions & 0 deletions tests/test_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
Loading