diff --git a/roles/sys-svc-proxy/templates/mcp/vhost.conf.j2 b/roles/sys-svc-proxy/templates/mcp/vhost.conf.j2 index 432d018fbb..6c74e7ae0f 100644 --- a/roles/sys-svc-proxy/templates/mcp/vhost.conf.j2 +++ b/roles/sys-svc-proxy/templates/mcp/vhost.conf.j2 @@ -1,23 +1,30 @@ -{% set mcp_path = lookup('config', application_id, 'mcp.endpoint.path', '') %} -{% if mcp_path %} +{% set mcp_endpoint_path = lookup('config', application_id, 'mcp.endpoint.path', '') %} +{% set mcp_upstream_path = lookup('config', application_id, 'mcp.adapter.upstream_path', '') %} +{% if mcp_endpoint_path or mcp_upstream_path %} {% set mcp_public = lookup('config', application_id, 'mcp.exposure', '') == 'public' and lookup('config', application_id, 'mcp.enabled', false) | bool %} {% set mcp_acl_gated = lookup('sso', application_id, 'is_proxy_gated') and (lookup('sso', application_id, 'oauth2_acl') | length) %} -{% if mcp_public and not mcp_acl_gated %} +{% if not mcp_acl_gated %} +{% set mcp_served = (mcp_endpoint_path | regex_replace('/+$', '')) if mcp_public else '' %} +{% if mcp_served %} {% set mcp_limits = lookup('config', application_id, 'mcp.limits') %} {% set mcp_zone = 'mcp_' ~ (application_id | replace('-', '_')) %} {% set sso_proxy_enabled = lookup('sso', application_id, 'is_proxy_gated') %} -{% set location = mcp_path | regex_replace('/+$', '') %} +{% set location = mcp_served %} {% set webserver_client_max_body_size = mcp_limits.request_bytes %} {% set webserver_proxy_send_timeout = mcp_limits.timeout_seconds ~ 's' %} {% set webserver_proxy_read_timeout = mcp_limits.stream_seconds ~ 's' %} {% set webserver_send_timeout = mcp_limits.stream_seconds ~ 's' %} {% set webserver_location_extra_configuration %}{% include 'roles/sys-svc-proxy/templates/mcp/directives.conf.j2' %}{% endset %} {% include 'roles/sys-svc-proxy/templates/location/html.conf.j2' %} -{% elif not mcp_acl_gated %} - location {{ mcp_path | regex_replace('/+$', '') }} { +{% endif %} +{% for hidden in [mcp_upstream_path, mcp_endpoint_path] | map('regex_replace', '/+$', '') | unique | select | list %} +{% if hidden != mcp_served %} + location {{ hidden }} { return 404; } {% endif %} +{% endfor %} +{% endif %} {% endif %} diff --git a/roles/web-app-baserow/meta/mcp.yml b/roles/web-app-baserow/meta/mcp.yml index 1a9548eadc..3fa7620921 100644 --- a/roles/web-app-baserow/meta/mcp.yml +++ b/roles/web-app-baserow/meta/mcp.yml @@ -24,6 +24,7 @@ adapter: upstream_transport: sse specification_path: roles/web-app-baserow/files/mcp/tools.json specification_sha256: sha256:716effccfedbb6e83cacd489bf0c2cf8f318853f9b20bf8fd733db4e637ec144 + upstream_path: /mcp endpoint: service_key: baserowmcp path: /mcp diff --git a/roles/web-app-baserow/vars/main.yml b/roles/web-app-baserow/vars/main.yml index 8778c77a5b..2e371cad41 100644 --- a/roles/web-app-baserow/vars/main.yml +++ b/roles/web-app-baserow/vars/main.yml @@ -35,7 +35,7 @@ BASEROW_INTERNAL_URL: "http://{{ BASEROW_CONTAINER }}:{{ lookup('config', applic BASEROW_MCP_ENABLED: "{{ lookup('config', application_id, 'mcp.enabled') }}" BASEROW_MCP_ENDPOINT_KEY: "{{ lookup('config', application_id, 'secrets.credentials.mcp_endpoint_key') }}" -BASEROW_MCP_UPSTREAM_PATH: "/mcp" +BASEROW_MCP_UPSTREAM_PATH: "{{ lookup('config', application_id, 'mcp.adapter.upstream_path') }}" BASEROW_MCP_ENDPOINT_PATH: "{{ lookup('config', application_id, 'mcp.endpoint.path') }}" BASEROW_MCP_ENDPOINT_NAME: "Infinito.Nexus" BASEROW_MCP_WORKSPACE_NAME: "Infinito.Nexus MCP" diff --git a/roles/web-app-gitlab/meta/mcp.yml b/roles/web-app-gitlab/meta/mcp.yml index af2fd9d6a2..9fc0a96fd2 100644 --- a/roles/web-app-gitlab/meta/mcp.yml +++ b/roles/web-app-gitlab/meta/mcp.yml @@ -23,6 +23,7 @@ adapter: type: mcp_passthrough specification_path: roles/web-app-gitlab/files/mcp/tools.json specification_sha256: sha256:ae5ffe0f6c8eeffcc4fe7da8ad1cc978c5f49337a64e30a060b6ab7a5dfb4b24 + upstream_path: /api/v4/mcp endpoint: service_key: gitlabmcp path: /mcp diff --git a/roles/web-app-gitlab/vars/main.yml b/roles/web-app-gitlab/vars/main.yml index b434a3cbc4..b9d6e25e81 100644 --- a/roles/web-app-gitlab/vars/main.yml +++ b/roles/web-app-gitlab/vars/main.yml @@ -84,7 +84,7 @@ GITLAB_MCP_CONTAINER: "{{ lookup('config', application_id, 'services.gitlabmcp.n GITLAB_MCP_PORT: "{{ lookup('config', application_id, 'services.gitlabmcp.ports.internal.http') }}" GITLAB_MCP_INSTANCE_DIR: "{{ [lookup('container', application_id, 'directories.instance'), 'mcp'] | path_join }}" GITLAB_MCP_UPSTREAM_HOST: "{{ GITLAB_WORKHORSE_SERVICE_NAME }}" -GITLAB_MCP_UPSTREAM_PATH: "/api/v4/mcp" +GITLAB_MCP_UPSTREAM_PATH: "{{ lookup('config', application_id, 'mcp.adapter.upstream_path') }}" GITLAB_MCP_UPSTREAM_URL: "http://{{ GITLAB_MCP_UPSTREAM_HOST }}:{{ GITLAB_WORKHORSE_PORT }}{{ GITLAB_MCP_UPSTREAM_PATH }}" GITLAB_MCP_BEARER: "{{ lookup('config', application_id, 'secrets.credentials.mcp_bearer') }}" GITLAB_MCP_CONTRACT: diff --git a/roles/web-app-jenkins/meta/mcp.yml b/roles/web-app-jenkins/meta/mcp.yml index 7b910f992c..0304128e4e 100644 --- a/roles/web-app-jenkins/meta/mcp.yml +++ b/roles/web-app-jenkins/meta/mcp.yml @@ -21,6 +21,7 @@ adapter: type: mcp_passthrough specification_path: roles/web-app-jenkins/files/mcp/tools.json specification_sha256: sha256:05d96a67cd799cdf2e74caaf4560c9282462ead555a6159fd2e0c74a705b6cf5 + upstream_path: /mcp-server/mcp endpoint: service_key: jenkinsmcp path: /mcp diff --git a/roles/web-app-jenkins/vars/main.yml b/roles/web-app-jenkins/vars/main.yml index b3bd602e08..ae5749dbaa 100644 --- a/roles/web-app-jenkins/vars/main.yml +++ b/roles/web-app-jenkins/vars/main.yml @@ -20,7 +20,7 @@ JENKINS_MCP_TOKEN_NAME: "infinito-mcp" JENKINS_MCP_TOKEN_FILE: "/var/jenkins_home/secrets/infinito-mcp.token" JENKINS_MCP_IDENTITY_URL: "{{ JENKINS_LOOPBACK_URL }}/whoAmI/api/json" JENKINS_MCP_READY_URL: "{{ JENKINS_LOOPBACK_URL }}/login" -JENKINS_MCP_PLUGIN_PATH: "/mcp-server/mcp" +JENKINS_MCP_PLUGIN_PATH: "{{ lookup('config', application_id, 'mcp.adapter.upstream_path') }}" JENKINS_MCP_URL: "{{ JENKINS_LOOPBACK_URL }}{{ JENKINS_MCP_PLUGIN_PATH }}" JENKINS_MCP_CONTAINER: "{{ lookup('config', application_id, 'services.jenkinsmcp.name') }}" JENKINS_MCP_PORT: "{{ lookup('config', application_id, 'services.jenkinsmcp.ports.internal.http') }}" diff --git a/roles/web-app-mattermost/meta/mcp.yml b/roles/web-app-mattermost/meta/mcp.yml index 6cb2838fcb..ec16067717 100644 --- a/roles/web-app-mattermost/meta/mcp.yml +++ b/roles/web-app-mattermost/meta/mcp.yml @@ -22,6 +22,7 @@ adapter: type: mcp_passthrough specification_path: roles/web-app-mattermost/files/mcp/tools.json specification_sha256: sha256:b06c02b100fa8d959d3e4d9fca42682df943967f6bf2de2c66f7031d5a6b823f + upstream_path: /plugins/mattermost-ai/mcp-server/mcp endpoint: service_key: mattermostmcp path: /mcp diff --git a/roles/web-app-mattermost/vars/main.yml b/roles/web-app-mattermost/vars/main.yml index 33089f8c37..d4122193a3 100644 --- a/roles/web-app-mattermost/vars/main.yml +++ b/roles/web-app-mattermost/vars/main.yml @@ -58,7 +58,7 @@ MATTERMOST_LDAP_CONTAINER_ADDRESS: "{{ lookup('container_address', 'svc-db-openl MATTERMOST_MCP_CONTAINER: "{{ lookup('config', application_id, 'services.mattermostmcp.name') }}" MATTERMOST_MCP_PORT: "{{ lookup('config', application_id, 'services.mattermostmcp.ports.internal.http') }}" MATTERMOST_MCP_INSTANCE_DIR: "{{ [lookup('container', application_id, 'directories.instance'), 'mcp'] | path_join }}" -MATTERMOST_MCP_UPSTREAM_PATH: "/plugins/{{ MATTERMOST_MCP_PLUGIN_ID }}/mcp-server/mcp" +MATTERMOST_MCP_UPSTREAM_PATH: "{{ lookup('config', application_id, 'mcp.adapter.upstream_path') }}" MATTERMOST_MCP_UPSTREAM_URL: "http://{{ MATTERMOST_CONTAINER }}:{{ container_port }}{{ MATTERMOST_MCP_UPSTREAM_PATH }}" MATTERMOST_MCP_BEARER: "{{ lookup('config', application_id, 'secrets.credentials.mcp_bearer') }}" MATTERMOST_MCP_CONTRACT: diff --git a/tests/unit/python/roles/sys-svc-proxy/templates/test_mcp_edge_limits.py b/tests/unit/python/roles/sys-svc-proxy/templates/test_mcp_edge_limits.py index c8fccd6eab..82f51849a0 100644 --- a/tests/unit/python/roles/sys-svc-proxy/templates/test_mcp_edge_limits.py +++ b/tests/unit/python/roles/sys-svc-proxy/templates/test_mcp_edge_limits.py @@ -198,5 +198,52 @@ class TestTheAppVhostServesOnlyPublicSurfaces(unittest.TestCase): self.assertEqual(served, {r for r in PUBLIC_ROLES if r != "web-app-wordpress"}) +class TestTheHiddenPathIsTheOneTheAppServes(unittest.TestCase): + """What must be hidden is the upstream, not the sidecar's endpoint. + + An adapter-fronted provider declares `endpoint.path` on its sidecar and + serves the real MCP surface somewhere else on its own vhost. Blocking the + sidecar's path there hides nothing: mattermost declares `/mcp` and answers + on `/plugins/mattermost-ai/mcp-server/mcp`, so the native surface stayed + reachable while the deploy looked correct. Baserow's two paths happen to + coincide, which is why one role passing proved nothing about the rest. + """ + + def passthrough_roles(self) -> dict[str, dict]: + found = {} + for meta in (PROJECT_ROOT / "roles").glob(f"*/{ROLE_FILE_META_MCP}"): + block = load_yaml(meta) + if (block.get("adapter") or {}).get("type") == "mcp_passthrough": + found[meta.parent.parent.name] = block + return found + + def test_a_passthrough_provider_says_where_its_upstream_lives(self): + """Either on the application's own vhost, or on a separate network.""" + for role, block in self.passthrough_roles().items(): + with self.subTest(role=role): + adapter = block["adapter"] + self.assertTrue( + adapter.get("upstream_path") or adapter.get("upstream_network"), + f"{role} declares neither upstream_path nor upstream_network, " + f"so nothing states whether its native surface is exposed", + ) + + def test_the_role_that_made_this_visible_declares_its_real_path(self): + adapter = self.passthrough_roles()["web-app-mattermost"]["adapter"] + self.assertEqual( + "/plugins/mattermost-ai/mcp-server/mcp", adapter["upstream_path"] + ) + self.assertNotEqual( + adapter["upstream_path"], + self.passthrough_roles()["web-app-mattermost"]["endpoint"]["path"], + ) + + def test_a_sidecar_upstream_declares_no_path_on_the_app_vhost(self): + """Gitea's upstream is its own service, so it has nothing to hide.""" + adapter = self.passthrough_roles()["web-app-gitea"]["adapter"] + self.assertNotIn("upstream_path", adapter) + self.assertTrue(adapter.get("upstream_network")) + + if __name__ == "__main__": unittest.main() diff --git a/utils/roles/applications/mcp.py b/utils/roles/applications/mcp.py index b520c2f86a..c9bf3463f4 100644 --- a/utils/roles/applications/mcp.py +++ b/utils/roles/applications/mcp.py @@ -29,7 +29,11 @@ Field vocabulary (see ``docs/contributing/design/role/services/mcp.md``): ``category_allowlist`` suits an upstream that switches whole categories and offers no per-tool filter, where a tool allowlist would be unenforceable; ``mcp_passthrough`` fronts an upstream that already speaks MCP instead of - translating REST. + translating REST. ``upstream_path`` names that upstream where it lives on the + application's own vhost, so the proxy can hide it: reaching it directly walks + past the allowlist the adapter exists to enforce. A provider whose upstream is + a separate sidecar declares ``upstream_network`` instead and has nothing to + hide there. * ``limits``: the request, response, timeout, concurrency, pagination and stream ceilings the surface enforces. * ``tools``: ``allowlist`` is what a reader may reach and @@ -198,6 +202,7 @@ MCP_ADAPTER_KEYS = frozenset( "specification_sha256", "scope", "upstream_transport", + "upstream_path", } )