Skip to content

Commit 0868ebf

Browse files
feat: MCP Bridge — external MCP servers via bridge templates (#32)
feat: MCP Bridge — external MCP servers via bridge templates
2 parents 18125e6 + 3a454be commit 0868ebf

7 files changed

Lines changed: 26 additions & 6 deletions

File tree

setup.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,16 @@ if [ -n "$HYBRID_ERRORS" ]; then
568568
fi
569569
echo " ✅ Hybrid search applied"
570570

571+
echo " Applying MCP Bridge migration..."
572+
BRIDGE_OUTPUT=$(LANG=C LC_ALL=C PGPASSWORD=$POSTGRES_PASSWORD psql -h localhost -U postgres -d postgres \
573+
-f supabase/migrations/006_mcp_bridge.sql 2>&1)
574+
BRIDGE_ERRORS=$(echo "$BRIDGE_OUTPUT" | grep -i "error" | head -5)
575+
if [ -n "$BRIDGE_ERRORS" ]; then
576+
echo -e " ${YELLOW}⚠️ MCP Bridge migration warnings:${NC}"
577+
echo "$BRIDGE_ERRORS" | while read line; do echo " $line"; done
578+
fi
579+
echo " ✅ MCP Bridge applied"
580+
571581
# Reload PostgREST schema cache so new tables are immediately available via API
572582
docker kill --signal=SIGUSR1 $(docker ps -q --filter name=rest) 2>/dev/null || true
573583

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- Migration 006: MCP Bridge support
2+
-- Adds auth columns to mcp_registry so external MCP servers (bridge templates)
3+
-- can be called with a bearer token or custom Authorization header.
4+
5+
ALTER TABLE public.mcp_registry
6+
ADD COLUMN IF NOT EXISTS auth_type text DEFAULT 'none',
7+
ADD COLUMN IF NOT EXISTS auth_token text;
8+
9+
COMMENT ON COLUMN public.mcp_registry.auth_type IS 'none | bearer | header';
10+
COMMENT ON COLUMN public.mcp_registry.auth_token IS 'Bearer token or full header value (plaintext, service-role access only)';

workflows/background-checker.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@
188188
"position": [736, 240],
189189
"parameters": {
190190
"description": "Calls a tool on an MCP server. Use when an installed MCP skill can help with the task. Required parameters: mcp_url (server URL), tool_name (tool to call), arguments (JSON object). Check available MCP servers in the system prompt.",
191-
"jsCode": "const qType = typeof query;\nlet mcpUrl = '', toolName = '', args = {};\n\nif (qType === 'object' && query !== null) {\n mcpUrl = query.mcp_url || '';\n toolName = query.tool_name || '';\n args = query.arguments || {};\n} else if (qType === 'string') {\n try {\n const parsed = JSON.parse(query);\n mcpUrl = parsed.mcp_url || '';\n toolName = parsed.tool_name || '';\n args = parsed.arguments || {};\n } catch(e) {\n return 'Parse error: query=' + String(query).substring(0,200) + ' type=' + qType;\n }\n} else {\n return 'Unexpected query type: ' + qType + ' value: ' + String(query).substring(0,200);\n}\n\nif (typeof args === 'string') try { args = JSON.parse(args); } catch(e) {}\nif (!mcpUrl || !toolName) return 'Fehler: mcp_url=' + mcpUrl + ' tool_name=' + toolName;\n\nfunction parseSSE(raw) {\n const str = String(raw);\n let lines = str.split('\\n');\n if (lines.length <= 1) lines = str.split('\\\\n');\n for (let i = lines.length - 1; i >= 0; i--) {\n if (lines[i].startsWith('data: ')) {\n return JSON.parse(lines[i].substring(6));\n }\n }\n return null;\n}\n\ntry {\n const hdrs = {'Content-Type':'application/json','Accept':'application/json, text/event-stream'};\n\n const init = await helpers.httpRequest({\n method: 'POST', url: mcpUrl,\n headers: hdrs,\n body: JSON.stringify({jsonrpc:'2.0',id:1,method:'initialize',params:{protocolVersion:'2024-11-05',capabilities:{},clientInfo:{name:'n8n-claw-bg-checker',version:'1.0'}}}),\n returnFullResponse: true, encoding: 'utf-8', json: false\n });\n const sid = init.headers && init.headers['mcp-session-id'];\n if (!sid) return 'MCP: Kein session-id. Body: ' + String(init.body||init).substring(0,200);\n\n hdrs['mcp-session-id'] = sid;\n\n await helpers.httpRequest({\n method: 'POST', url: mcpUrl,\n headers: hdrs,\n body: JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'}),\n json: false\n });\n\n const listResp = await helpers.httpRequest({\n method: 'POST', url: mcpUrl,\n headers: hdrs,\n body: JSON.stringify({jsonrpc:'2.0',id:2,method:'tools/list',params:{}}),\n json: false\n });\n const listResult = parseSSE(listResp);\n if (listResult && listResult.result && listResult.result.tools) {\n const tool = listResult.result.tools.find(t => t.name === toolName);\n if (tool && tool.inputSchema && tool.inputSchema.required) {\n for (const req of tool.inputSchema.required) {\n if (args[req] === undefined || args[req] === null) {\n args[req] = '';\n }\n }\n }\n }\n\n const resp = await helpers.httpRequest({\n method: 'POST', url: mcpUrl,\n headers: hdrs,\n body: JSON.stringify({jsonrpc:'2.0',id:3,method:'tools/call',params:{name:toolName,arguments:args}}),\n returnFullResponse: true, encoding: 'utf-8', json: false\n });\n\n const result = parseSSE(resp.body || resp);\n if (!result) return 'MCP: Keine Daten. Response: ' + String(resp.body || resp).substring(0,500);\n if (result.error) return 'MCP Error: ' + JSON.stringify(result.error);\n return result.result?.content?.[0]?.text || JSON.stringify(result.result);\n} catch(e) {\n return 'MCP Error: ' + e.message;\n}",
191+
"jsCode": "const qType = typeof query;\nlet mcpUrl = '', toolName = '', args = {};\n\nif (qType === 'object' && query !== null) {\n mcpUrl = query.mcp_url || '';\n toolName = query.tool_name || '';\n args = query.arguments || {};\n} else if (qType === 'string') {\n try {\n const parsed = JSON.parse(query);\n mcpUrl = parsed.mcp_url || '';\n toolName = parsed.tool_name || '';\n args = parsed.arguments || {};\n } catch(e) {\n return 'Parse error: query=' + String(query).substring(0,200) + ' type=' + qType;\n }\n} else {\n return 'Unexpected query type: ' + qType + ' value: ' + String(query).substring(0,200);\n}\n\nif (typeof args === 'string') try { args = JSON.parse(args); } catch(e) {}\nif (!mcpUrl || !toolName) return 'Fehler: mcp_url=' + mcpUrl + ' tool_name=' + toolName;\n\nfunction parseSSE(raw) {\n const str = String(raw);\n let lines = str.split('\\n');\n if (lines.length <= 1) lines = str.split('\\\\n');\n for (let i = lines.length - 1; i >= 0; i--) {\n if (lines[i].startsWith('data: ')) {\n return JSON.parse(lines[i].substring(6));\n }\n }\n return null;\n}\n\ntry {\n let authHeader = null;\n try {\n const row = await helpers.httpRequest({method:'GET',url:'{{SUPABASE_URL}}/rest/v1/mcp_registry?mcp_url=eq.'+encodeURIComponent(mcpUrl)+'&select=auth_type,auth_token&limit=1',headers:{apikey:'{{SUPABASE_SERVICE_KEY}}',Authorization:'Bearer {{SUPABASE_SERVICE_KEY}}'},json:true});\n if (row && row[0] && row[0].auth_type && row[0].auth_type !== 'none' && row[0].auth_token) {\n authHeader = row[0].auth_type === 'bearer' ? 'Bearer '+row[0].auth_token : row[0].auth_token;\n }\n } catch(e) {}\n\n const hdrs = {'Content-Type':'application/json','Accept':'application/json, text/event-stream'};\n if (authHeader) hdrs['Authorization'] = authHeader;\n\n const init = await helpers.httpRequest({\n method: 'POST', url: mcpUrl,\n headers: hdrs,\n body: JSON.stringify({jsonrpc:'2.0',id:1,method:'initialize',params:{protocolVersion:'2024-11-05',capabilities:{},clientInfo:{name:'n8n-claw-bg-checker',version:'1.0'}}}),\n returnFullResponse: true, encoding: 'utf-8', json: false\n });\n const sid = init.headers && init.headers['mcp-session-id'];\n if (sid) hdrs['mcp-session-id'] = sid;\n\n await helpers.httpRequest({\n method: 'POST', url: mcpUrl,\n headers: hdrs,\n body: JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'}),\n json: false\n });\n\n const listResp = await helpers.httpRequest({\n method: 'POST', url: mcpUrl,\n headers: hdrs,\n body: JSON.stringify({jsonrpc:'2.0',id:2,method:'tools/list',params:{}}),\n json: false\n });\n const listResult = parseSSE(listResp);\n if (listResult && listResult.result && listResult.result.tools) {\n const tool = listResult.result.tools.find(t => t.name === toolName);\n if (tool && tool.inputSchema && tool.inputSchema.required) {\n for (const req of tool.inputSchema.required) {\n if (args[req] === undefined || args[req] === null) {\n args[req] = '';\n }\n }\n }\n }\n\n const resp = await helpers.httpRequest({\n method: 'POST', url: mcpUrl,\n headers: hdrs,\n body: JSON.stringify({jsonrpc:'2.0',id:3,method:'tools/call',params:{name:toolName,arguments:args}}),\n returnFullResponse: true, encoding: 'utf-8', json: false\n });\n\n const result = parseSSE(resp.body || resp);\n if (!result) return 'MCP: Keine Daten. Response: ' + String(resp.body || resp).substring(0,500);\n if (result.error) return 'MCP Error: ' + JSON.stringify(result.error);\n return result.result?.content?.[0]?.text || JSON.stringify(result.result);\n} catch(e) {\n return 'MCP Error: ' + e.message;\n}",
192192
"specifyInputSchema": false
193193
}
194194
},

workflows/credential-form.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
},
3131
{
3232
"parameters": {
33-
"jsCode": "const input = $input.first().json;\nconst token = input.formQueryParameters?.token;\nconst apiKey = input['Credential Value'];\n\nif (!token) throw new Error('No token provided.');\nif (!apiKey) throw new Error('No API key provided.');\n\nconst baseUrl = '{{SUPABASE_URL}}';\nconst serviceKey = '{{SUPABASE_SERVICE_KEY}}';\nconst headers = {\n 'apikey': serviceKey,\n 'Authorization': 'Bearer ' + serviceKey,\n 'Content-Type': 'application/json'\n};\n\n// Validate token\nconst rows = await helpers.httpRequest({\n method: 'GET',\n url: baseUrl + '/rest/v1/credential_tokens?token=eq.' + token + '&select=*',\n headers\n});\n\nif (!rows || !Array.isArray(rows) || rows.length === 0) {\n throw new Error('Invalid or unknown token.');\n}\n\nconst row = rows[0];\nif (row.used) throw new Error('This link has already been used.');\nif (new Date(row.expires_at) < new Date()) throw new Error('This link has expired.');\n\n// Delete existing credential (if any) then insert new one\nawait helpers.httpRequest({\n method: 'DELETE',\n url: baseUrl + '/rest/v1/template_credentials?template_id=eq.' + encodeURIComponent(row.template_id) + '&cred_key=eq.' + encodeURIComponent(row.cred_key),\n headers: { ...headers, 'Prefer': 'return=minimal' }\n});\nawait helpers.httpRequest({\n method: 'POST',\n url: baseUrl + '/rest/v1/template_credentials',\n headers: { ...headers, 'Prefer': 'return=minimal' },\n body: {\n template_id: row.template_id,\n cred_key: row.cred_key,\n cred_value: apiKey,\n updated_at: new Date().toISOString()\n }\n});\n\n// Mark token as used\nawait helpers.httpRequest({\n method: 'PATCH',\n url: baseUrl + '/rest/v1/credential_tokens?token=eq.' + token,\n headers: { ...headers, 'Prefer': 'return=minimal' },\n body: { used: true }\n});\n\nreturn [{ json: { success: true } }];"
33+
"jsCode": "const input = $input.first().json;\nconst token = input.formQueryParameters?.token;\nconst apiKey = input['Credential Value'];\n\nif (!token) throw new Error('No token provided.');\nif (!apiKey) throw new Error('No API key provided.');\n\nconst baseUrl = '{{SUPABASE_URL}}';\nconst serviceKey = '{{SUPABASE_SERVICE_KEY}}';\nconst headers = {\n 'apikey': serviceKey,\n 'Authorization': 'Bearer ' + serviceKey,\n 'Content-Type': 'application/json'\n};\n\n// Validate token\nconst rows = await helpers.httpRequest({\n method: 'GET',\n url: baseUrl + '/rest/v1/credential_tokens?token=eq.' + token + '&select=*',\n headers\n});\n\nif (!rows || !Array.isArray(rows) || rows.length === 0) {\n throw new Error('Invalid or unknown token.');\n}\n\nconst row = rows[0];\nif (row.used) throw new Error('This link has already been used.');\nif (new Date(row.expires_at) < new Date()) throw new Error('This link has expired.');\n\n// Delete existing credential (if any) then insert new one\nawait helpers.httpRequest({\n method: 'DELETE',\n url: baseUrl + '/rest/v1/template_credentials?template_id=eq.' + encodeURIComponent(row.template_id) + '&cred_key=eq.' + encodeURIComponent(row.cred_key),\n headers: { ...headers, 'Prefer': 'return=minimal' }\n});\nawait helpers.httpRequest({\n method: 'POST',\n url: baseUrl + '/rest/v1/template_credentials',\n headers: { ...headers, 'Prefer': 'return=minimal' },\n body: {\n template_id: row.template_id,\n cred_key: row.cred_key,\n cred_value: apiKey,\n updated_at: new Date().toISOString()\n }\n});\n\n// Mark token as used\nawait helpers.httpRequest({\n method: 'PATCH',\n url: baseUrl + '/rest/v1/credential_tokens?token=eq.' + token,\n headers: { ...headers, 'Prefer': 'return=minimal' },\n body: { used: true }\n});\n\n// For bridge templates: also mirror auth_token into mcp_registry so the inline MCP client can read it.\n// No-op (0 rows) if this template is not a bridge.\nif (row.cred_key === 'auth_token') {\n try {\n await helpers.httpRequest({\n method: 'PATCH',\n url: baseUrl + '/rest/v1/mcp_registry?template_id=eq.' + encodeURIComponent(row.template_id) + '&template_type=eq.bridge',\n headers: { ...headers, 'Prefer': 'return=minimal' },\n body: { auth_type: 'bearer', auth_token: apiKey }\n });\n } catch(e) { /* non-fatal */ }\n}\n\nreturn [{ json: { success: true } }];"
3434
},
3535
"name": "Validate and Save",
3636
"type": "n8n-nodes-base.code",
@@ -58,4 +58,4 @@
5858
"settings": {
5959
"executionOrder": "v1"
6060
}
61-
}
61+
}

workflows/mcp-library-manager.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)