Skip to content

Commit b2ebaa8

Browse files
committed
Add public API hardening plan for v2
Adds docs/public-api-plan.md: a comprehensive plan to tighten the public API surface by enforcing a private-by-default policy. The plan covers: - Audit findings: ~40 leaked implementation modules, missing __all__ declarations, name collisions, and absent re-exports - Design principles (private-by-default, stable import paths, automated enforcement) - Tooling recommendations (griffe-based CI audit script, pyright reportPrivateUsage, ruff __all__ rules, optional type stubs) - Full enumerated allowlist across 8 tiers: core entry points, transports, MCPServer types, auth, elicitation, callbacks, protocol types, experimental - Explicit privatization tables: which modules get renamed to _module.py, which internal names get prefixed with _ - Needs-decision items for team discussion - Four implementation phases from tooling setup through type stubs
1 parent 2137c8f commit b2ebaa8

File tree

1 file changed

+376
-0
lines changed

1 file changed

+376
-0
lines changed

docs/public-api-plan.md

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
# Public API Hardening Plan for v2
2+
3+
## Problem Statement
4+
5+
In v1, nearly every module, class, function, and type was importable by end users—even internal implementation details. This meant routine refactors (renaming a helper, reordering a base class, changing an internal constant) could break downstream code. The root cause: there was no enforced distinction between "public" and "private."
6+
7+
Audit of the current surface reveals:
8+
9+
- **~40 implementation modules** have no `_` prefix and are directly importable, but are never surfaced through any `__init__.py`—they are accidental public API
10+
- **Zero implementation modules** (except `_httpx_utils.py`) declare `__all__`, so star imports from them leak every top-level name
11+
- **Name collisions** exist (two independent `RequestContext` classes in `shared.context` and `client.streamable_http`)
12+
- **`shared/` has an empty `__init__.py`**—every internal utility is importable via `from mcp.shared.session import BaseSession`
13+
- **`server/auth/` subtree has zero re-exports**—all auth internals are reachable by deep module path but never declared public
14+
- **The `mcp/__init__.py` imports `ServerSession` directly from `server/session.py`**, bypassing `server/__init__.py`
15+
16+
## Design Principles
17+
18+
1. **Private by default.** Every symbol starts as private. Public status must be explicitly granted via `__all__` in the package `__init__.py`.
19+
2. **Module filenames enforce privacy.** Implementation files are named `_module.py`. Only `__init__.py` files are the public face of each package.
20+
3. **Stable import paths.** Users import from package roots (`mcp`, `mcp.types`, `mcp.server`, `mcp.client`), never from leaf implementation files.
21+
4. **Protocol types are inherently public.** The `mcp.types` module mirrors the MCP spec schema—every type in the spec is public.
22+
5. **Experimental is opt-in and explicitly unstable.** Anything under `.experimental` namespaces carries no stability guarantee.
23+
6. **Enforcement is automated.** A CI-checked allowlist script prevents accidental surface growth.
24+
25+
## Methodology
26+
27+
The enumeration uses a "private-by-default, explicitly public" approach:
28+
29+
1. **Enumerate** every importable symbol in the package (automated — see Tooling)
30+
2. **Classify** each symbol: Public / Private / Experimental / Needs-Decision
31+
3. **Public symbols**: surface through the correct `__init__.py` + `__all__`, reachable from a stable path
32+
4. **Private symbols**: rename the containing module to `_module.py`, or prefix the name with `_`
33+
5. **Needs-Decision**: explicit team call before shipping
34+
6. **Enforce**: CI audit script compares the live importable surface against the checked-in allowlist on every PR
35+
36+
---
37+
38+
## Recommended Tooling
39+
40+
### 1. Custom CI audit script (`scripts/audit_public_api.py`) — **most important**
41+
42+
This is the enforcement mechanism that makes the public API a reviewable artifact. It does:
43+
44+
- Walks all modules in `src/mcp/`
45+
- Flags any non-`__init__.py` module without a `_` prefix as a "leaked module"
46+
- Flags any `__init__.py` missing `__all__`
47+
- Collects the full public surface (union of all `__all__` declarations)
48+
- Compares against a checked-in allowlist (`docs/public_api_allowlist.txt`)
49+
- Fails if any new name appears that isn't allowlisted, or if an allowlisted name disappears
50+
51+
This runs in CI on every PR. Adding to the public API becomes an explicit, reviewable choice.
52+
53+
### 2. `griffe` — already available via `mkdocstrings`
54+
55+
`griffe` extracts the full public API from source without executing code, respecting `__all__` and `_` conventions. Since it's already a dependency for the docs build, it can power the audit script or standalone analysis:
56+
57+
```python
58+
from griffe import load
59+
60+
package = load("mcp")
61+
# package.members gives the full public surface tree
62+
```
63+
64+
This also means the mkdocs API reference docs automatically reflect only the declared public surface once we tighten the modules.
65+
66+
### 3. `pyright` `reportPrivateUsage` — already enabled in strict mode
67+
68+
Once modules are renamed to `_module.py`, pyright will flag any import from them outside the `mcp` package. This gives free enforcement for test code and downstream users without additional configuration.
69+
70+
### 4. `ruff` rules already in place
71+
72+
- `F822` catches undefined names in `__all__`
73+
- `F401` catches unused imports (drift between import and `__all__`)
74+
- These already run in pre-commit; they'll catch `__init__.py` drift automatically
75+
76+
### 5. Type stubs (`.pyi`) — Phase 3, long-term
77+
78+
Generate `.pyi` stubs for the public surface and check them in. API changes become visible as stub file diffs in PRs. Tooling: `stubgen` (mypy) or griffe can generate them.
79+
80+
---
81+
82+
## The Public API Allowlist
83+
84+
Everything below is explicitly public. Everything not listed becomes private.
85+
86+
### Tier 1: Core Entry Points
87+
88+
Import from `mcp` or `mcp.client` / `mcp.server`.
89+
90+
| Symbol | Stable import path | Notes |
91+
|---|---|---|
92+
| `Client` | `mcp.Client` | Main client entry point (wraps in-memory server) |
93+
| `ClientSession` | `mcp.ClientSession` | Full-featured session over any transport |
94+
| `ClientSessionGroup` | `mcp.ClientSessionGroup` | Multi-server aggregation |
95+
| `MCPServer` | `mcp.server.MCPServer` | High-level decorator-based server |
96+
| `Server` | `mcp.server.Server` | Low-level handler-registration server |
97+
| `ServerSession` | `mcp.ServerSession` | Server-side session |
98+
| `Context` | `mcp.server.mcpserver.Context` | Request context passed to MCPServer handlers |
99+
| `MCPError` | `mcp.MCPError` | Base exception for MCP protocol errors |
100+
| `UrlElicitationRequiredError` | `mcp.UrlElicitationRequiredError` | Thrown when URL elicitation is required |
101+
102+
### Tier 2: Transports & Connection Parameters
103+
104+
Transport entry-point functions and their configuration types. Currently these leak via direct module import; they should be surfaced through `__init__.py`.
105+
106+
| Symbol | Proposed stable path | Current location |
107+
|---|---|---|
108+
| `stdio_client` | `mcp.stdio_client` | `mcp.client.stdio` ✓ already re-exported |
109+
| `stdio_server` | `mcp.stdio_server` | `mcp.server.stdio` ✓ already re-exported |
110+
| `sse_client` | `mcp.client.sse_client` | `mcp.client.sse`**not re-exported** |
111+
| `streamable_http_client` | `mcp.client.streamable_http_client` | `mcp.client.streamable_http`**not re-exported** |
112+
| `websocket_client` | `mcp.client.websocket_client` | `mcp.client.websocket`**not re-exported** |
113+
| `StdioServerParameters` | `mcp.StdioServerParameters` | ✓ already re-exported |
114+
| `SseServerParameters` | `mcp.client.SseServerParameters` | `mcp.client.session_group`**not re-exported** |
115+
| `StreamableHttpParameters` | `mcp.client.StreamableHttpParameters` | `mcp.client.session_group`**not re-exported** |
116+
| `ServerParameters` | `mcp.client.ServerParameters` | `mcp.client.session_group`**not re-exported** |
117+
| `ClientSessionParameters` | `mcp.client.ClientSessionParameters` | `mcp.client.session_group`**not re-exported** |
118+
| `SseServerTransport` | `mcp.server.SseServerTransport` | `mcp.server.sse`**not re-exported** |
119+
| `EventStore` | `mcp.server.EventStore` | `mcp.server.streamable_http`**not re-exported** |
120+
| `TransportSecuritySettings` | `mcp.server.TransportSecuritySettings` | `mcp.server.transport_security`**not re-exported** |
121+
122+
### Tier 3: MCPServer Resource / Tool / Prompt Types
123+
124+
User-facing wrapper types for defining server capabilities.
125+
126+
| Symbol | Stable import path |
127+
|---|---|
128+
| `Tool` | `mcp.server.mcpserver.tools.Tool` |
129+
| `Resource` | `mcp.server.mcpserver.resources.Resource` |
130+
| `TextResource` | `mcp.server.mcpserver.resources.TextResource` |
131+
| `BinaryResource` | `mcp.server.mcpserver.resources.BinaryResource` |
132+
| `FunctionResource` | `mcp.server.mcpserver.resources.FunctionResource` |
133+
| `FileResource` | `mcp.server.mcpserver.resources.FileResource` |
134+
| `HttpResource` | `mcp.server.mcpserver.resources.HttpResource` |
135+
| `DirectoryResource` | `mcp.server.mcpserver.resources.DirectoryResource` |
136+
| `ResourceTemplate` | `mcp.server.mcpserver.resources.ResourceTemplate` |
137+
| `Prompt` | `mcp.server.mcpserver.prompts.Prompt` |
138+
| `Image` | `mcp.server.mcpserver.Image` |
139+
| `Audio` | `mcp.server.mcpserver.Audio` |
140+
| `NotificationOptions` | `mcp.server.NotificationOptions` |
141+
| `InitializationOptions` | `mcp.server.InitializationOptions` |
142+
143+
### Tier 4: Auth
144+
145+
Client and server auth types. Currently all server auth types are importable only via deep paths with zero `__init__.py` re-export.
146+
147+
| Symbol | Proposed stable path | Current location |
148+
|---|---|---|
149+
| `OAuthClientProvider` | `mcp.client.auth.OAuthClientProvider` | ✓ already re-exported |
150+
| `TokenStorage` | `mcp.client.auth.TokenStorage` | ✓ already re-exported |
151+
| `PKCEParameters` | `mcp.client.auth.PKCEParameters` | ✓ already re-exported |
152+
| `OAuthFlowError` | `mcp.client.auth.OAuthFlowError` | ✓ already re-exported |
153+
| `OAuthRegistrationError` | `mcp.client.auth.OAuthRegistrationError` | ✓ already re-exported |
154+
| `OAuthTokenError` | `mcp.client.auth.OAuthTokenError` | ✓ already re-exported |
155+
| `OAuthAuthorizationServerProvider` | `mcp.server.auth.OAuthAuthorizationServerProvider` | `mcp.server.auth.provider`**not re-exported** |
156+
| `TokenVerifier` | `mcp.server.auth.TokenVerifier` | `mcp.server.auth.provider`**not re-exported** |
157+
| `AuthSettings` | `mcp.server.auth.AuthSettings` | `mcp.server.auth.settings`**not re-exported** |
158+
159+
### Tier 5: Elicitation Result Types
160+
161+
Returned from `Context.elicit()` and `Context.elicit_url()` in MCPServer handlers.
162+
163+
| Symbol | Proposed stable path | Current location |
164+
|---|---|---|
165+
| `ElicitationResult` | `mcp.server.ElicitationResult` | `mcp.server.elicitation`**not re-exported** |
166+
| `UrlElicitationResult` | `mcp.server.UrlElicitationResult` | `mcp.server.elicitation`**not re-exported** |
167+
| `AcceptedElicitation` | `mcp.server.AcceptedElicitation` | `mcp.server.elicitation`**not re-exported** |
168+
| `DeclinedElicitation` | `mcp.server.DeclinedElicitation` | `mcp.server.elicitation`**not re-exported** |
169+
| `CancelledElicitation` | `mcp.server.CancelledElicitation` | `mcp.server.elicitation`**not re-exported** |
170+
171+
### Tier 6: Callback Protocol Types
172+
173+
Users need these for type annotations when passing callbacks to `Client` or `ClientSession`. Currently defined in `client/session.py` with no re-export.
174+
175+
| Symbol | Proposed stable path |
176+
|---|---|
177+
| `SamplingFnT` | `mcp.client.SamplingFnT` |
178+
| `ElicitationFnT` | `mcp.client.ElicitationFnT` |
179+
| `ListRootsFnT` | `mcp.client.ListRootsFnT` |
180+
| `LoggingFnT` | `mcp.client.LoggingFnT` |
181+
| `MessageHandlerFnT` | `mcp.client.MessageHandlerFnT` |
182+
183+
### Tier 7: Protocol Types (`mcp.types`)
184+
185+
**All ~200 types currently in `mcp.types.__all__` remain public.** These mirror the MCP spec and are inherently part of the public contract. The internal `mcp.types._types` is already correctly private. The `mcp.types.jsonrpc` module should be renamed to `_jsonrpc.py` (everything is already re-exported through `types/__init__.py`).
186+
187+
### Tier 8: Experimental (Explicitly Unstable)
188+
189+
Everything under `*.experimental.*` namespaces. Importable, but documented as "may change without notice" and not covered by semver stability.
190+
191+
---
192+
193+
## What Gets Privatized
194+
195+
### Implementation Modules to Rename
196+
197+
Rename from `module.py` to `_module.py`. All public symbols are preserved via re-exports in the package `__init__.py`.
198+
199+
**`src/mcp/client/`**
200+
201+
| Current | Renamed | Re-export destination |
202+
|---|---|---|
203+
| `client.py` | `_client.py` | `client/__init__.py` |
204+
| `session.py` | `_session.py` | `client/__init__.py` |
205+
| `session_group.py` | `_session_group.py` | `client/__init__.py` |
206+
| `sse.py` | `_sse.py` | `client/__init__.py` |
207+
| `stdio.py` | `_stdio.py` | `client/__init__.py` + `mcp/__init__.py` |
208+
| `streamable_http.py` | `_streamable_http.py` | `client/__init__.py` |
209+
| `websocket.py` | `_websocket.py` | `client/__init__.py` |
210+
211+
**`src/mcp/client/auth/`**
212+
213+
| Current | Renamed |
214+
|---|---|
215+
| `exceptions.py` | `_exceptions.py` |
216+
| `oauth2.py` | `_oauth2.py` |
217+
| `utils.py` | `_utils.py` |
218+
| `extensions/client_credentials.py` | `extensions/_client_credentials.py` |
219+
220+
**`src/mcp/server/`**
221+
222+
| Current | Renamed | Re-export destination |
223+
|---|---|---|
224+
| `session.py` | `_session.py` | `mcp/__init__.py` |
225+
| `sse.py` | `_sse.py` | `server/__init__.py` |
226+
| `stdio.py` | `_stdio.py` | `mcp/__init__.py` |
227+
| `streamable_http.py` | `_streamable_http.py` | `server/__init__.py` |
228+
| `streamable_http_manager.py` | `_streamable_http_manager.py` | — (internal only) |
229+
| `transport_security.py` | `_transport_security.py` | `server/__init__.py` |
230+
| `validation.py` | `_validation.py` | — (internal only) |
231+
| `elicitation.py` | `_elicitation.py` | `server/__init__.py` |
232+
| `models.py` | `_models.py` | `server/__init__.py` |
233+
| `websocket.py` | `_websocket.py` | — (internal only, server side) |
234+
235+
**`src/mcp/server/lowlevel/`**
236+
237+
| Current | Renamed |
238+
|---|---|
239+
| `server.py` | `_server.py` |
240+
| `func_inspection.py` | `_func_inspection.py` |
241+
| `helper_types.py` | `_helper_types.py` |
242+
| `experimental.py` | `_experimental.py` |
243+
244+
**`src/mcp/server/mcpserver/`**
245+
246+
| Current | Renamed |
247+
|---|---|
248+
| `server.py` | `_server.py` |
249+
| `exceptions.py` | `_exceptions.py` |
250+
| `prompts/base.py` | `prompts/_base.py` |
251+
| `prompts/manager.py` | `prompts/_manager.py` |
252+
| `resources/base.py` | `resources/_base.py` |
253+
| `resources/resource_manager.py` | `resources/_resource_manager.py` |
254+
| `resources/templates.py` | `resources/_templates.py` |
255+
| `resources/types.py` | `resources/_types.py` |
256+
| `tools/base.py` | `tools/_base.py` |
257+
| `tools/tool_manager.py` | `tools/_tool_manager.py` |
258+
| `utilities/context_injection.py` | `utilities/_context_injection.py` |
259+
| `utilities/func_metadata.py` | `utilities/_func_metadata.py` |
260+
| `utilities/logging.py` | `utilities/_logging.py` |
261+
| `utilities/types.py` | `utilities/_types.py` |
262+
263+
**`src/mcp/server/auth/` (entire subtree)**
264+
265+
All handler, middleware, route, and settings files → prefix with `_`. Re-export public types through `auth/__init__.py`.
266+
267+
**`src/mcp/shared/` (entire package is internal)**
268+
269+
| Current | Renamed |
270+
|---|---|
271+
| `auth.py` | `_auth.py` |
272+
| `auth_utils.py` | `_auth_utils.py` |
273+
| `context.py` | `_context.py` |
274+
| `exceptions.py` | `_exceptions.py` |
275+
| `memory.py` | `_memory.py` |
276+
| `message.py` | `_message.py` |
277+
| `metadata_utils.py` | `_metadata_utils.py` |
278+
| `progress.py` | `_progress.py` |
279+
| `response_router.py` | `_response_router.py` |
280+
| `session.py` | `_session.py` |
281+
| `tool_name_validation.py` | `_tool_name_validation.py` |
282+
| `version.py` | `_version.py` |
283+
284+
**`src/mcp/types/`**
285+
286+
| Current | Renamed |
287+
|---|---|
288+
| `jsonrpc.py` | `_jsonrpc.py` |
289+
290+
**`src/mcp/cli/`**
291+
292+
| Current | Renamed |
293+
|---|---|
294+
| `cli.py` | `_cli.py` |
295+
| `claude.py` | `_claude.py` |
296+
297+
**`src/mcp/os/`**
298+
299+
| Current | Renamed |
300+
|---|---|
301+
| `posix/utilities.py` | `posix/_utilities.py` |
302+
| `win32/utilities.py` | `win32/_utilities.py` |
303+
304+
### Internal Names to Prefix with `_`
305+
306+
These are names inside modules that should not be part of any public surface:
307+
308+
| Name | Module | Reason |
309+
|---|---|---|
310+
| `DEFAULT_CLIENT_INFO` | `client/session.py` | Implementation detail |
311+
| `ClientResponse` (TypeAdapter) | `client/session.py` | Internal deserialization |
312+
| `PROCESS_TERMINATION_TIMEOUT` | `client/stdio.py` | Hardcoded internal timeout |
313+
| `request_ctx` (ContextVar) | `server/lowlevel/server.py` | Internal context propagation |
314+
| `StructuredContent` | `server/lowlevel/server.py` | Internal type alias |
315+
| `UnstructuredContent` | `server/lowlevel/server.py` | Internal type alias |
316+
| `CombinationContent` | `server/lowlevel/server.py` | Internal type alias |
317+
| `Settings` | `server/mcpserver/server.py` | Internal config class |
318+
| `lifespan_wrapper` | `server/mcpserver/server.py` | Internal helper |
319+
| `remove_request_params` | `client/sse.py` | Internal URL helper |
320+
| All `MCP_SESSION_ID`, `MCP_PROTOCOL_VERSION`, `LAST_EVENT_ID` constants | transport modules | Internal protocol constants |
321+
| `SessionMessageOrError`, `StreamWriter`, `StreamReader`, `GetSessionIdCallback` | `client/streamable_http.py` | Internal type aliases |
322+
| `RequestContext` (dataclass) | `client/streamable_http.py` | Collides with `shared.context.RequestContext`; internal |
323+
324+
---
325+
326+
## Needs-Decision Items
327+
328+
These require an explicit team call. They are ambiguous and have arguments on both sides.
329+
330+
| Item | Location | Question |
331+
|---|---|---|
332+
| `get_default_environment()` | `client/stdio.py` | Do users need to call this? Or just customize `StdioServerParameters.env`? |
333+
| `DEFAULT_INHERITED_ENV_VARS` | `client/stdio.py` | Same — users might want to reference the default list |
334+
| `ToolManager`, `ResourceManager`, `PromptManager` | `server/mcpserver/` sub-packages | Currently exported. Do users need these directly, or is `MCPServer.add_tool()` sufficient? |
335+
| `StreamableHTTPTransport` | `client/streamable_http.py` | Do users need the transport class directly for advanced customization? |
336+
| `StreamableHTTPServerTransport` | `server/streamable_http.py` | Same question, server side |
337+
| `MCPServerError` and subclasses | `server/mcpserver/exceptions.py` | Should users be able to `except MCPServerError`? Probably yes — add to Tier 1 |
338+
| `RequestResponder` | `shared/session.py` | Needed if `ServerSession.incoming_messages` is public. Is it? |
339+
| `BaseSession` | `shared/session.py` | Should advanced users subclass? Likely no |
340+
341+
---
342+
343+
## Implementation Phases
344+
345+
### Phase 0: Tooling & Allowlist (do first, blocks everything else)
346+
347+
- Write `scripts/audit_public_api.py` using `griffe` to enumerate the live surface
348+
- Generate `docs/public_api_allowlist.txt` from the Tier tables above
349+
- Wire the audit script into CI (can add as a ruff-style pre-commit hook or a dedicated CI step)
350+
- Resolve the "Needs-Decision" items and update the allowlist
351+
352+
### Phase 1: Module Renames (one large batch)
353+
354+
- Rename all implementation modules as specified in the tables above
355+
- Update all internal imports across `src/mcp/`
356+
- Update all `__init__.py` re-exports to point to new `_`-prefixed paths
357+
- Update all test imports
358+
- Run the full test suite; fix any breakage
359+
360+
### Phase 2: `__init__.py` Surface Consolidation
361+
362+
- Add missing re-exports (transport functions, auth types, elicitation types, callback protocols) to the appropriate `__init__.py` files
363+
- Ensure every `__init__.py` has `__all__`
364+
- Verify stable import paths match the allowlist tables
365+
366+
### Phase 3: Internal Name Cleanup
367+
368+
- Prefix internal constants, type aliases, and helpers with `_` as specified in the "Internal Names" table
369+
- Resolve the `RequestContext` name collision
370+
- Run pyright; verify no new `reportPrivateUsage` warnings from tests
371+
372+
### Phase 4: Type Stubs (optional, long-term)
373+
374+
- Generate `.pyi` stubs covering only the public surface
375+
- Check them into the repo
376+
- Review stub diffs in PRs as an API review signal

0 commit comments

Comments
 (0)