This is an automated email from the ASF dual-hosted git repository.
FreeOnePlus pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
The following commit(s) were added to refs/heads/master by this push:
new ae477a3 docs: define subscription capability boundary (#142)
ae477a3 is described below
commit ae477a3aeb6763e9b15baab9e17798b799b42cf8
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 10:17:38 2026 +0800
docs: define subscription capability boundary (#142)
---
CHANGELOG.md | 3 +
README.md | 15 +++
.../0001-subscriptions-require-change-events.md | 87 +++++++++++++++++
test/integration/test_real_doris_transports.py | 38 +++++++-
test/protocol/test_mcp_v2_protocol.py | 107 +++++++++++++++++++++
5 files changed, 249 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e363112..d43ae69 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -49,6 +49,9 @@ under **Unreleased** until a new version is selected and
published.
`serverInfo`, and health endpoints on one product version source.
- Documented the modern request headers, migration steps, and deployment
constraints.
+- Recorded `subscriptions/listen` as intentionally unavailable until a real
+ cross-worker Doris change-event source exists, with discovery kept
+ capability-honest.
- Positioned Dynamic Client Registration as a compatibility fallback behind
preconfigured clients and Client ID Metadata Documents.
- Persisted static bearer tokens as self-describing SHA-256/SHA-512 digests,
diff --git a/README.md b/README.md
index fc1a68a..3d82c4c 100644
--- a/README.md
+++ b/README.md
@@ -560,6 +560,21 @@ changes, or under another principal returns `Invalid
Params`. Restart the
listing without a cursor in that case. This prevents a multi-page traversal
from silently duplicating, dropping, or crossing permission-scoped entries.
+### Subscriptions and Change Notifications
+
+The server does not currently advertise or serve `subscriptions/listen`.
+Tools and prompts have no runtime mutation channel, and Doris catalog metadata
+does not provide this process with a reliable cross-worker change-event source.
+Cache expiry and periodic catalog polling are not treated as change events.
+
+Consequently, MCP 2026-07-28 discovery reports `listChanged: false` for tools,
+prompts, and resources and `subscribe: false` for resources. A
+`subscriptions/listen` request returns `Method not found`; clients should
+refresh lists explicitly. Streamable HTTP and true subprocess stdio tests
+enforce this boundary. See
+[the subscription decision
record](docs/decisions/0001-subscriptions-require-change-events.md)
+for the conditions required before the capability can be enabled.
+
### Migrating from MCP 2025-11-25
1. Upgrade the client to a `2026-07-28`-capable MCP SDK.
diff --git a/docs/decisions/0001-subscriptions-require-change-events.md
b/docs/decisions/0001-subscriptions-require-change-events.md
new file mode 100644
index 0000000..14e774c
--- /dev/null
+++ b/docs/decisions/0001-subscriptions-require-change-events.md
@@ -0,0 +1,87 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+# Subscriptions require a real change-event source
+
+- Status: Accepted
+- Date: 2026-07-30
+- Protocol: MCP 2026-07-28
+
+## Context
+
+MCP 2026-07-28 delivers server change notifications through the long-lived
+`subscriptions/listen` request. Registering that request handler also causes
+the SDK to advertise `listChanged` for tools, prompts, and resources, plus
+resource subscription support.
+
+Doris MCP Server currently has no event source that can uphold those
+capabilities:
+
+- tools and prompts are assembled when the process starts and have no runtime
+ mutation channel;
+- Doris resources are read from catalog metadata on demand;
+- the bounded metadata cache has time-based expiry, but expiry is not evidence
+ that a catalog object changed;
+- Doris DDL can be performed outside this server, so observing only tool calls
+ made through this process would miss changes;
+- multiple workers and replicas have no shared catalog-event bus.
+
+Polling a full catalog and treating cache expiry as a change event would create
+false positives, detection gaps, identity-scoping ambiguity, and inconsistent
+behavior across replicas. A handler that never publishes events would be worse:
+discovery would promise a capability that cannot work.
+
+## Decision
+
+Doris MCP Server does not register `subscriptions/listen` until it has a
+reliable change-event source.
+
+For MCP 2026-07-28:
+
+- discovery reports `listChanged: false` for tools, prompts, and resources;
+- discovery reports `subscribe: false` for resources;
+- `subscriptions/listen` returns the standard `Method not found` error;
+- Streamable HTTP and a true subprocess stdio test enforce this boundary and
+ verify that a rejected listen request does not affect later requests.
+
+The older `resources/subscribe` request is not used as a substitute on the
+modern protocol.
+
+## Revisit criteria
+
+Subscription support may be added when all of the following exist:
+
+1. a change source that detects Doris catalog changes regardless of whether
+ they originated through this server;
+2. identity-aware resource filtering that cannot reveal a URI after the
+ principal loses access;
+3. shared fan-out for every configured worker and replica;
+4. bounded buffering, disconnect cleanup, and explicit no-replay behavior;
+5. Streamable HTTP, subprocess stdio, multiworker, authorization, and failure
+ recovery tests against the real event source.
+
+At that point the SDK subscription bus and listen handler can carry typed
+events. Capability flags must be enabled only for event kinds the server can
+actually publish.
+
+## Consequences
+
+Clients must refresh lists explicitly. They can rely on discovery rather than
+opening a stream that will remain silent. The server avoids background catalog
+polling and does not claim unsupported real-time behavior.
diff --git a/test/integration/test_real_doris_transports.py
b/test/integration/test_real_doris_transports.py
index 0a76f1c..0db2d2b 100644
--- a/test/integration/test_real_doris_transports.py
+++ b/test/integration/test_real_doris_transports.py
@@ -47,8 +47,14 @@ from typing import Any
import httpx
import pymysql
import pytest
-from mcp import Client, StdioServerParameters
+from mcp import Client, MCPError, StdioServerParameters
from mcp.client.stdio import stdio_client
+from mcp.types import (
+ SubscriptionFilter,
+ SubscriptionsListenRequest,
+ SubscriptionsListenRequestParams,
+ SubscriptionsListenResult,
+)
from doris_mcp_server import __version__
@@ -444,6 +450,36 @@ async def
test_real_doris_protocol_lists_paginate_without_loss(
environment,
read_timeout_seconds=60,
) as client:
+ capabilities = client.server_capabilities
+ assert capabilities is not None
+ assert capabilities.tools is not None
+ assert capabilities.tools.list_changed is False
+ assert capabilities.prompts is not None
+ assert capabilities.prompts.list_changed is False
+ assert capabilities.resources is not None
+ assert capabilities.resources.list_changed is False
+ assert capabilities.resources.subscribe is False
+
+ with pytest.raises(MCPError) as unsupported:
+ await client.session.send_request(
+ SubscriptionsListenRequest(
+ params=SubscriptionsListenRequestParams(
+ notifications=SubscriptionFilter(
+ tools_list_changed=True,
+ prompts_list_changed=True,
+ resources_list_changed=True,
+ resource_subscriptions=[
+ f"doris://table/{doris_sandbox.table}"
+ ],
+ )
+ )
+ ),
+ SubscriptionsListenResult,
+ request_read_timeout_seconds=5,
+ )
+ assert unsupported.value.code == -32601
+ assert unsupported.value.message == "Method not found"
+
for list_method, result_field, identifier in (
(
client.list_resources,
diff --git a/test/protocol/test_mcp_v2_protocol.py
b/test/protocol/test_mcp_v2_protocol.py
index 30ee3f3..e23212c 100644
--- a/test/protocol/test_mcp_v2_protocol.py
+++ b/test/protocol/test_mcp_v2_protocol.py
@@ -32,6 +32,10 @@ from mcp.types import (
PromptMessage,
Resource,
SamplingCapability,
+ SubscriptionFilter,
+ SubscriptionsListenRequest,
+ SubscriptionsListenRequestParams,
+ SubscriptionsListenResult,
TextContent,
Tool,
)
@@ -434,6 +438,67 @@ async def
test_http_discover_is_stateless_and_unknown_method_does_not_kill_serve
assert second.json()["result"]["resultType"] == "complete"
[email protected]
+async def
test_http_does_not_advertise_or_serve_subscriptions_without_change_source():
+ server = create_test_server()
+ assert server.get_request_handler("subscriptions/listen") is None
+ app = server.streamable_http_app(
+ json_response=True,
+ stateless_http=True,
+ host="127.0.0.1",
+ transport_security=create_transport_security("127.0.0.1"),
+ )
+
+ async with (
+ app.router.lifespan_context(app),
+ httpx2.ASGITransport(app) as transport,
+ httpx2.AsyncClient(
+ transport=transport,
+ base_url="http://127.0.0.1:3000",
+ ) as client,
+ ):
+ discovered = await client.post(
+ "/mcp",
+ json=modern_request(1, "server/discover"),
+ headers=modern_headers("server/discover"),
+ )
+ assert discovered.status_code == 200
+ capabilities = discovered.json()["result"]["capabilities"]
+ assert capabilities["tools"]["listChanged"] is False
+ assert capabilities["prompts"]["listChanged"] is False
+ assert capabilities["resources"] == {
+ "subscribe": False,
+ "listChanged": False,
+ }
+
+ listen_request = modern_request(2, "subscriptions/listen")
+ listen_request["params"]["notifications"] = {
+ "toolsListChanged": True,
+ "promptsListChanged": True,
+ "resourcesListChanged": True,
+ "resourceSubscriptions": ["doris://table/orders"],
+ }
+ rejected = await client.post(
+ "/mcp",
+ json=listen_request,
+ headers=modern_headers("subscriptions/listen"),
+ )
+ assert rejected.status_code == 404
+ assert rejected.json()["error"] == {
+ "code": -32601,
+ "message": "Method not found",
+ "data": "subscriptions/listen",
+ }
+
+ recovered = await client.post(
+ "/mcp",
+ json=modern_request(3, "server/discover"),
+ headers=modern_headers("server/discover"),
+ )
+ assert recovered.status_code == 200
+ assert recovered.json()["result"]["resultType"] == "complete"
+
+
@pytest.mark.asyncio
async def test_http_rejects_untrusted_origin_and_legacy_adapter_is_stateless():
app = DorisMCPHTTPTransport(
@@ -1039,6 +1104,48 @@ async def
test_stdio_validates_capabilities_versions_and_process_survival():
)
[email protected]
+async def
test_true_subprocess_stdio_does_not_advertise_or_serve_subscriptions():
+ server_script = Path(__file__).with_name("stdio_capability_server.py")
+ server_params = StdioServerParameters(
+ command=sys.executable,
+ args=[str(server_script)],
+ )
+
+ async with Client(stdio_client(server_params)) as modern:
+ capabilities = modern.server_capabilities
+ assert capabilities is not None
+ assert capabilities.tools is not None
+ assert capabilities.tools.list_changed is False
+ assert capabilities.prompts is not None
+ assert capabilities.prompts.list_changed is False
+ assert capabilities.resources is not None
+ assert capabilities.resources.list_changed is False
+ assert capabilities.resources.subscribe is False
+
+ listen_request = SubscriptionsListenRequest(
+ params=SubscriptionsListenRequestParams(
+ notifications=SubscriptionFilter(
+ tools_list_changed=True,
+ prompts_list_changed=True,
+ resources_list_changed=True,
+ resource_subscriptions=["doris://table/orders"],
+ )
+ )
+ )
+ with pytest.raises(MCPError) as unsupported:
+ await modern.session.send_request(
+ listen_request,
+ SubscriptionsListenResult,
+ request_read_timeout_seconds=1,
+ )
+ assert unsupported.value.code == -32601
+ assert unsupported.value.message == "Method not found"
+
+ recovered = await modern.list_resources(cache_mode="bypass")
+ assert recovered.result_type == "complete"
+
+
@pytest.mark.asyncio
async def test_stdio_enforces_tool_specific_client_capabilities_and_recovers():
server_script = Path(__file__).with_name("conformance_server.py")
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]