diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
index 261624757..c5803a38d 100644
--- a/.github/workflows/deploy-docs.yml
+++ b/.github/workflows/deploy-docs.yml
@@ -27,7 +27,7 @@ jobs:
uses: ./.github/actions/setup-python-env
- name: Build documentation
- run: uv run zensical build --clean
+ run: make docs-build
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 47f40afbe..fb9e64fee 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -122,7 +122,7 @@ jobs:
uses: ./.github/actions/setup-python-env
- name: Build documentation
- run: uv run zensical build --clean
+ run: make docs-build
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
diff --git a/.gitignore b/.gitignore
index 656fbfe30..555028b59 100644
--- a/.gitignore
+++ b/.gitignore
@@ -176,6 +176,7 @@ venv.bak/
# mkdocs/zensical documentation
/site
+/docs/api/openapi.yaml
e2e/bub/results/
# mypy
diff --git a/Makefile b/Makefile
index a5f39e7c5..7ec2c3b0c 100644
--- a/Makefile
+++ b/Makefile
@@ -148,13 +148,20 @@ publish: ## Publish a release to PyPI.
.PHONY: build-and-publish
build-and-publish: build publish ## Build and publish.
+.PHONY: docs-build
+docs-build: ## Build the documentation and publish the canonical OpenAPI contract.
+ @install -D -m 0644 openapi/powercontext.yaml docs/api/openapi.yaml
+ @trap 'rm -f docs/api/openapi.yaml' EXIT; uv run zensical build --clean -s
+
.PHONY: docs-test
-docs-test: ## Test if documentation can be built without warnings or errors
- @uv run zensical build -s
+docs-test: docs-build ## Test if documentation can be built without warnings or errors
+ @test -f site/api/index.html
+ @cmp --silent openapi/powercontext.yaml site/api/openapi.yaml
.PHONY: docs
docs: ## Build and serve the documentation
- @uv run zensical serve
+ @install -D -m 0644 openapi/powercontext.yaml docs/api/openapi.yaml
+ @trap 'rm -f docs/api/openapi.yaml' EXIT; uv run zensical serve $(ARGS)
.PHONY: help
help:
diff --git a/docs/api/index.html b/docs/api/index.html
new file mode 100644
index 000000000..2208ec609
--- /dev/null
+++ b/docs/api/index.html
@@ -0,0 +1,39 @@
+
+
+
+
+ PowerContext HTTP API Reference
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/en/docs/reference/interfaces.md b/docs/en/docs/reference/interfaces.md
index 661268c4a..495c939de 100644
--- a/docs/en/docs/reference/interfaces.md
+++ b/docs/en/docs/reference/interfaces.md
@@ -312,10 +312,12 @@ want the supplied SQLite or OceanBase-backed implementation in the same process.
## HTTP and MCP
See [HTTP API](http-api.md) for authentication, curl examples, operation groups, errors, and the complete OpenAPI
-contract. The Server publishes its OpenAPI document at `/openapi.json`, readiness at `/health/ready`, capabilities at
-`/v1/capabilities`, and Streamable HTTP MCP at `/mcp` by default. HTTP is the complete application contract. MCP is a
-curated agent-facing projection of Memory and Candidate Review operations. The five Candidate Review operations use
-the same validation, `expected_version` concurrency checks, and approval transaction over HTTP and MCP.
+contract. The Server publishes a Scalar API reference at `/docs`, its OpenAPI document at `/openapi.json`, readiness at
+`/health/ready`, capabilities at `/v1/capabilities`, and Streamable HTTP MCP at `/mcp` by default. The Scalar reference
+remains public when bearer authentication is enabled, but the operations it describes retain their normal authentication
+requirements. HTTP is the complete application contract. MCP is a curated agent-facing projection of Memory and
+Candidate Review operations. The five Candidate Review operations use the same validation, `expected_version`
+concurrency checks, and approval transaction over HTTP and MCP.
Readiness is `ready` with HTTP 200 when all checks pass, `degraded` with HTTP 200 when only configured inference checks
fail, and `not_ready` with HTTP 503 when the Runtime or database fails. Dependency checks use `ready`, `unavailable`,
`timeout`, or `misconfigured`; an intentionally unbound Runtime reports `not_ready` for the `runtime` check.
diff --git a/docs/zh/docs/reference/interfaces.md b/docs/zh/docs/reference/interfaces.md
index 3042aac53..7455389ee 100644
--- a/docs/zh/docs/reference/interfaces.md
+++ b/docs/zh/docs/reference/interfaces.md
@@ -287,12 +287,13 @@ Discovery 不进入 Review。显式调用 `import_external_skill` 并提供精
## HTTP 和 MCP
-鉴权、curl 示例、操作分组、错误格式和完整 OpenAPI 契约见 [HTTP API](http-api.md)。Server 在
-`/openapi.json` 提供 OpenAPI 文档,在 `/health/ready` 提供就绪检查,在 `/v1/capabilities`
-提供能力信息,并默认在 `/mcp` 提供 Streamable HTTP MCP。HTTP 是完整应用契约,MCP 是面向 Agent 的
-Memory 与 Candidate Review operation 子集。五个 Candidate Review operation 通过 HTTP 和 MCP 使用相同的
-validation、`expected_version` 并发校验和 approval transaction。Experience/Skill generation、exact read、
-external Registry operation 和低阶 proposal operation 仍只通过 HTTP 提供。
+鉴权、curl 示例、操作分组、错误格式和完整 OpenAPI 契约见 [HTTP API](http-api.md)。Server 在 `/docs` 提供
+Scalar API reference,在 `/openapi.json` 提供 OpenAPI 文档,在 `/health/ready` 提供就绪检查,在
+`/v1/capabilities` 提供能力信息,并默认在 `/mcp` 提供 Streamable HTTP MCP。启用 Bearer authentication 后,
+Scalar reference 仍可公开访问,但其中描述的 operation 继续遵守各自的认证要求。HTTP 是完整应用契约,MCP 是
+面向 Agent 的 Memory 与 Candidate Review operation 子集。五个 Candidate Review operation 通过 HTTP 和 MCP
+使用相同的 validation、`expected_version` 并发校验和 approval transaction。Experience/Skill generation、
+exact read、external Registry operation 和低阶 proposal operation 仍只通过 HTTP 提供。
所有检查通过时 readiness 为 HTTP 200 的 `ready`;只有已配置的推理检查失败时为 HTTP 200 的 `degraded`;
Runtime 或数据库失败时为 HTTP 503 的 `not_ready`。依赖检查使用 `ready`、`unavailable`、`timeout` 或
`misconfigured`;有意不绑定 Runtime 时,`runtime` 检查使用 `not_ready`。
diff --git a/e2e/bub/uv.lock b/e2e/bub/uv.lock
index 3a361263c..435a3197c 100644
--- a/e2e/bub/uv.lock
+++ b/e2e/bub/uv.lock
@@ -1649,6 +1649,7 @@ requires-dist = [
{ name = "pyobvector", marker = "extra == 'seekdb'", specifier = ">=0.2.28,<0.3" },
{ name = "pyobvector", marker = "extra == 'server'", specifier = ">=0.2.28,<0.3" },
{ name = "rfc8785", specifier = ">=0.1.4,<1" },
+ { name = "scalar-fastapi", marker = "extra == 'server'", specifier = ">=1.8.2,<2" },
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'builtin'", specifier = ">=2,<3" },
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'seekdb'", specifier = ">=2,<3" },
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'server'", specifier = ">=2,<3" },
@@ -1697,7 +1698,7 @@ dependencies = [
requires-dist = [
{ name = "bub", specifier = ">=0.4.0,<0.5.0" },
{ name = "httpx", specifier = ">=0.28,<1" },
- { name = "powercontext", extras = ["client"], specifier = ">=0.0.3" },
+ { name = "powercontext", extras = ["client"] },
{ name = "pydantic-settings", specifier = ">=2.7,<3" },
]
diff --git a/integrations/bub/pyproject.toml b/integrations/bub/pyproject.toml
index 4a3f36128..8063d100f 100644
--- a/integrations/bub/pyproject.toml
+++ b/integrations/bub/pyproject.toml
@@ -20,7 +20,7 @@ requires-python = ">=3.12,<4.0"
dependencies = [
"bub>=0.4.0,<0.5.0",
"httpx>=0.28,<1",
- "powercontext[client]>=0.0.3",
+ "powercontext[client]",
"pydantic-settings>=2.7,<3",
]
diff --git a/pyproject.toml b/pyproject.toml
index 81bcb5874..d84445595 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -65,6 +65,7 @@ server = [
"powercontext[builtin]",
"prometheus-client>=0.21,<1",
"pydantic-settings>=2.7,<3",
+ "scalar-fastapi>=1.8.2,<2",
"uvicorn>=0.34,<1",
]
tracing-otlp = [
diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py
index 8cfd96edd..ebe042b4f 100644
--- a/src/powercontext/server/app.py
+++ b/src/powercontext/server/app.py
@@ -32,6 +32,7 @@
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from opentelemetry.trace import SpanKind
+from scalar_fastapi import AgentScalarConfig, get_scalar_api_reference
from starlette.middleware import Middleware
from starlette.middleware.base import RequestResponseEndpoint
from starlette.types import Lifespan
@@ -390,6 +391,7 @@
from powercontext.server.metrics import ServerMetrics
from powercontext.server.tracing import ServerTracing
+_SCALAR_JS_URL = "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.66.1"
REQUEST_ID_HEADER = "X-PowerContext-Request-ID"
REPORT_SELECTION_DIGEST_HEADER = "X-PowerContext-Selection-Digest"
REPORT_DIGEST_HEADER = "X-PowerContext-Report-Digest"
@@ -571,6 +573,8 @@ def create_app(
title=API_TITLE,
version=API_VERSION,
description=API_DESCRIPTION,
+ docs_url=None,
+ redoc_url=None,
lifespan=lifespan,
middleware=list(middleware),
)
@@ -686,6 +690,12 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse:
_add_route(app, APPROVE_ARTIFACT_CANDIDATE, approve_artifact_candidate)
_add_route(app, REJECT_ARTIFACT_CANDIDATE, reject_artifact_candidate)
_add_route(app, REVISE_ARTIFACT_CANDIDATE, revise_artifact_candidate)
+ app.add_api_route(
+ "/docs",
+ scalar_api_reference,
+ include_in_schema=False,
+ methods=["GET"],
+ )
def canonical_openapi() -> dict[str, Any]:
if app.openapi_schema is None:
@@ -701,6 +711,21 @@ def canonical_openapi() -> dict[str, Any]:
return app
+async def scalar_api_reference(request: Request) -> Response:
+ """Render the runtime OpenAPI contract with Scalar."""
+
+ return get_scalar_api_reference(
+ content=request.app.openapi(),
+ title=f"{API_TITLE} Reference",
+ scalar_js_url=_SCALAR_JS_URL,
+ scalar_favicon_url="data:,",
+ with_default_fonts=False,
+ show_developer_tools="never",
+ telemetry=False,
+ agent=AgentScalarConfig(disabled=True),
+ )
+
+
async def get_liveness() -> HealthResponse:
return HealthResponse(status="ok")
diff --git a/src/powercontext/server/middleware.py b/src/powercontext/server/middleware.py
index abd9891cf..605140f04 100644
--- a/src/powercontext/server/middleware.py
+++ b/src/powercontext/server/middleware.py
@@ -25,7 +25,7 @@
from powercontext.http import ErrorDetail, ErrorResponse
from powercontext.server.context import is_internal_bridge
-_PUBLIC_PATHS = frozenset({"/", "/handoff-reports", "/reviews", "/skills", "/health/live", "/health/ready"})
+_PUBLIC_PATHS = frozenset({"/", "/docs", "/handoff-reports", "/reviews", "/skills", "/health/live", "/health/ready"})
_PUBLIC_PATH_PREFIXES = ("/static/",)
diff --git a/tests/test_server.py b/tests/test_server.py
index 9c541f11b..ab62a3473 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -276,6 +276,20 @@ def test_liveness_adds_a_server_owned_request_id() -> None:
assert "X-Request-ID" not in response.headers
+def test_scalar_reference_embeds_the_canonical_openapi_contract() -> None:
+ client = TestClient(create_app())
+ response = client.get("/docs")
+
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ assert "PowerContext API Reference" in response.text
+ assert '"openapi": "3.0.3"' in response.text
+ assert '"/v1/context/prepare"' in response.text
+ assert "@scalar/api-reference@1.66.1" in response.text
+ assert "proxyUrl" not in response.text
+ assert client.get("/scalar").status_code == 404
+
+
def test_server_factory_optionally_requires_bearer_authentication() -> None:
app = create_server_app(
settings=ServerSettings(
@@ -291,6 +305,7 @@ def test_server_factory_optionally_requires_bearer_authentication() -> None:
protected_metrics = client.get("/metrics")
accepted_metrics = client.get("/metrics", headers={"Authorization": "Bearer server-secret"})
liveness = client.get("/health/live")
+ scalar_reference = client.get("/docs")
assert missing.status_code == 401
assert missing.headers["WWW-Authenticate"] == "Bearer"
@@ -307,6 +322,7 @@ def test_server_factory_optionally_requires_bearer_authentication() -> None:
assert protected_metrics.status_code == 401
assert accepted_metrics.status_code == 200
assert liveness.status_code == 200
+ assert scalar_reference.status_code == 200
def test_readiness_reports_unavailable_bindings() -> None:
diff --git a/uv.lock b/uv.lock
index 8191a6a45..01d4f1495 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2131,6 +2131,7 @@ server = [
{ name = "pydantic-ai-slim", extra = ["anthropic", "openai"] },
{ name = "pydantic-settings" },
{ name = "pyobvector" },
+ { name = "scalar-fastapi" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "sqlite-vec" },
{ name = "uvicorn" },
@@ -2196,6 +2197,7 @@ requires-dist = [
{ name = "pyobvector", marker = "extra == 'seekdb'", specifier = ">=0.2.28,<0.3" },
{ name = "pyobvector", marker = "extra == 'server'", specifier = ">=0.2.28,<0.3" },
{ name = "rfc8785", specifier = ">=0.1.4,<1" },
+ { name = "scalar-fastapi", marker = "extra == 'server'", specifier = ">=1.8.2,<2" },
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'builtin'", specifier = ">=2,<3" },
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'seekdb'", specifier = ">=2,<3" },
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'server'", specifier = ">=2,<3" },
@@ -3173,6 +3175,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
]
+[[package]]
+name = "scalar-fastapi"
+version = "1.8.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/81/c0c70776a3d7d371ee06d38d26a8d361c97439d46f79acb6d67cf6c760ad/scalar_fastapi-1.8.2.tar.gz", hash = "sha256:0de09b8c63f78c1052792faa200d740b2ccaeeb88ac54e7ea633ac4edc6fde82", size = 8371, upload-time = "2026-04-09T22:41:24.267Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d8/b1/b9a482620479b2801a4dfa5c4c10b4cc280097e894114e0298f21a0fca55/scalar_fastapi-1.8.2-py3-none-any.whl", hash = "sha256:d96e2c8b3676491eaebb4ec8d9f4de77adb2374d86f87d321546fa6f084e8cb8", size = 7677, upload-time = "2026-04-09T22:41:23.209Z" },
+]
+
[[package]]
name = "secretstorage"
version = "3.5.0"
diff --git a/zensical.toml b/zensical.toml
index 3365122da..608c1a12e 100644
--- a/zensical.toml
+++ b/zensical.toml
@@ -46,6 +46,7 @@ nav = [
{ "Interfaces" = "en/docs/reference/interfaces.md" },
{ "HTTP API" = "en/docs/reference/http-api.md" },
{ "Configuration" = "en/docs/reference/configuration.md" },
+ { "HTTP API Reference" = "api/index.html" },
] },
] },
{ "Development" = [
@@ -127,6 +128,7 @@ nav = [
{ "接口" = "zh/docs/reference/interfaces.md" },
{ "HTTP API" = "zh/docs/reference/http-api.md" },
{ "配置" = "zh/docs/reference/configuration.md" },
+ { "HTTP API 参考" = "api/index.html" },
] },
] },
{ "开发" = [