From 3827552909c3e4ce06e23cf2fed517a043cb20fd Mon Sep 17 00:00:00 2001 From: thanhnv Date: Thu, 20 Aug 2026 20:20:04 +0700 Subject: [PATCH 1/2] fix(security): remove shared unlock defaults --- config.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/config.py b/config.py index 7b29aac..ba07910 100644 --- a/config.py +++ b/config.py @@ -105,7 +105,7 @@ DEFAULT_CONFIG: Dict[str, Any] = { # sandboxes agent-run shell commands) — reading a URL for info is safe and # useful, so this defaults ON. Toggle in Settings → Security. "allow_url_fetch": True, - "sandbox_pw": "quandh14", # default password to unlock sandbox settings + "sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD "rulebase_path": "", # custom RULEBASE.md — attached to every agent execution }, # Legacy generic-MCP-server list. MERGED into ext_connectors["other"] as of @@ -173,7 +173,7 @@ DEFAULT_CONFIG: Dict[str, Any] = { # Microsoft. Real Outlook/Teams/OneDrive/SharePoint access still requires a # proper OAuth sign-in (not implemented yet) using tenant_id/client_id below. "ms365": { - "unlock_code": "quandh14", + "unlock_code": "", # set through COWORK_MS365_UNLOCK_CODE "unlocked": False, # runtime-only — never persisted as True, see save() # Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server # launches automatically once the user is signed in (OAuth tenant/client @@ -294,6 +294,10 @@ def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]: data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"] if os.getenv("COWORK_CA_BUNDLE"): data["tls_ca_bundle"] = os.environ["COWORK_CA_BUNDLE"] + if os.getenv("COWORK_SANDBOX_PASSWORD"): + data["agent_security"]["sandbox_pw"] = os.environ["COWORK_SANDBOX_PASSWORD"] + if os.getenv("COWORK_MS365_UNLOCK_CODE"): + data["ms365"]["unlock_code"] = os.environ["COWORK_MS365_UNLOCK_CODE"] return data From 202925e6ed62f1090d82da6dd92912df378e994e Mon Sep 17 00:00:00 2001 From: thanhnv Date: Thu, 20 Aug 2026 20:21:06 +0700 Subject: [PATCH 2/2] feat(mcp): scaffold three project context tools --- docs/project-context-mcp-team-guide.md | 58 +++++ mcp_servers/project_context/__init__.py | 5 + mcp_servers/project_context/foundation.py | 106 ++++++++ .../project_context/providers/__init__.py | 1 + .../project_context/providers/change.py | 25 ++ .../project_context/providers/issue.py | 25 ++ .../project_context/providers/knowledge.py | 25 ++ mcp_servers/project_context/registry.py | 23 ++ mcp_servers/project_context/runtime.py | 74 ++++++ mcp_servers/project_context/server.py | 142 ++++++++++ mcp_servers/project_context/tools/__init__.py | 1 + .../project_context/tools/change_context.py | 55 ++++ .../project_context/tools/issue_context.py | 59 +++++ .../project_context/tools/knowledge_search.py | 58 +++++ mcp_servers/project_context_server.py | 9 + tests/conftest.py | 10 + tests/test_project_context_mcp_template.py | 245 ++++++++++++++++++ 17 files changed, 921 insertions(+) create mode 100644 docs/project-context-mcp-team-guide.md create mode 100644 mcp_servers/project_context/__init__.py create mode 100644 mcp_servers/project_context/foundation.py create mode 100644 mcp_servers/project_context/providers/__init__.py create mode 100644 mcp_servers/project_context/providers/change.py create mode 100644 mcp_servers/project_context/providers/issue.py create mode 100644 mcp_servers/project_context/providers/knowledge.py create mode 100644 mcp_servers/project_context/registry.py create mode 100644 mcp_servers/project_context/runtime.py create mode 100644 mcp_servers/project_context/server.py create mode 100644 mcp_servers/project_context/tools/__init__.py create mode 100644 mcp_servers/project_context/tools/change_context.py create mode 100644 mcp_servers/project_context/tools/issue_context.py create mode 100644 mcp_servers/project_context/tools/knowledge_search.py create mode 100644 mcp_servers/project_context_server.py create mode 100644 tests/conftest.py create mode 100644 tests/test_project_context_mcp_template.py diff --git a/docs/project-context-mcp-team-guide.md b/docs/project-context-mcp-team-guide.md new file mode 100644 index 0000000..2c91aa1 --- /dev/null +++ b/docs/project-context-mcp-team-guide.md @@ -0,0 +1,58 @@ +# Project Context MCP — hướng dẫn làm song song + +Mục tiêu: hoàn thiện ba tool trên **cùng một server** `project_context`. Không tạo server, registry, +policy hay error envelope mới. Shared skeleton đã khóa sẵn thứ tự an toàn: + +```text +validate input → policy ALLOW → resolve provider → gọi upstream → validate output +``` + +## Chia việc + +| Người | Tool | Chỉ sửa | Branch đề xuất | +|---|---|---|---| +| Member A | `get_project_issue_context` | `tools/issue_context.py`, `providers/issue.py`, test riêng | `feat/mcp-issue-context` | +| Member B | `search_project_knowledge` | `tools/knowledge_search.py`, `providers/knowledge.py`, test riêng | `feat/mcp-knowledge-search` | +| Member C | `get_project_change_context` | `tools/change_context.py`, `providers/change.py`, test riêng | `feat/mcp-change-context` | + +Trước khi gửi task, thay `Member A/B/C` bằng username thật trên ba issue. Mỗi người **không sửa** +`foundation.py`, `registry.py`, `runtime.py`, `server.py` hoặc file của người khác. Nếu shared contract +cần đổi, mở một PR nhỏ riêng và để cả ba người rebase sau khi PR đó merge. + +## Bắt đầu trong 5 phút + +1. Chạy `python --version` và xác nhận Python 3.11+ như baseline trong `requirements.txt`. +2. Tạo branch từ commit template chứa tài liệu này sau khi PR template merge. +3. Đọc input/output model trong module tool được giao; không thêm field riêng của Gitea/Jira/Redmine. +4. Implement provider read-only trong module `providers/.py`; credential chỉ lấy sau policy ALLOW. +5. Thêm test happy, invalid, not-found, timeout, DENIED với `resolver.calls == 0`, output sai schema, + truncation/cursor và source mở được có `revision`. +6. Chạy: + + ```bash + python -m pytest tests/test_project_context_mcp_template.py tests/test_project_context_.py -q + ``` + + Lệnh trên chạy trực tiếp từ root repo `cowork_local`; `tests/conftest.py` đã thiết lập import path. + +## Definition of Done của từng người + +- Tool trả đúng schema, có `project_id` và source gồm `system`, `url`, `revision`, `retrieved_at`. +- Provider-neutral: đổi Gitea sang GitHub/Jira/Redmine không đổi schema hay tool name. +- Sai project bị `DENIED` trước khi resolve credential và trước mọi upstream call. +- Không log/return token; lỗi ngoài dự kiến không lộ exception; read không có side effect. +- Output lớn có `truncated`, `returned`, `remaining`, `next_cursor`; không cắt im lặng. +- Test riêng pass, test shared pass, PR chỉ chạm đúng vùng sở hữu trong bảng trên. + +## Chạy server sau khi provider đã cấu hình + +```bash +COWORK_MCP_ACTOR_ID= \ +COWORK_MCP_ORG_UNIT= \ +COWORK_MCP_CUSTOMER= \ +COWORK_MCP_PROJECT= \ +python -m cowork_local.mcp_servers.project_context_server +``` + +Không commit giá trị môi trường hoặc credential. Cowork kết nối bằng stdio với command Python và +args `-m cowork_local.mcp_servers.project_context_server`. diff --git a/mcp_servers/project_context/__init__.py b/mcp_servers/project_context/__init__.py new file mode 100644 index 0000000..5c2ad4b --- /dev/null +++ b/mcp_servers/project_context/__init__.py @@ -0,0 +1,5 @@ +"""Provider-neutral Project Context MCP server template.""" + +from .server import build_server, dispatch + +__all__ = ["build_server", "dispatch"] diff --git a/mcp_servers/project_context/foundation.py b/mcp_servers/project_context/foundation.py new file mode 100644 index 0000000..675d638 --- /dev/null +++ b/mcp_servers/project_context/foundation.py @@ -0,0 +1,106 @@ +"""Shared, stable boundary used by all Project Context tool work packages.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Protocol + +from pydantic import AnyUrl, BaseModel, ConfigDict, Field + + +class ContractModel(BaseModel): + """Strict immutable model so provider-specific fields cannot leak to the Agent.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + +class IdentityContext(ContractModel): + actor_id: str = Field(min_length=1, max_length=256) + org_unit: str = Field(min_length=1, max_length=128) + customer: str = Field(min_length=1, max_length=128) + project: str = Field(min_length=1, max_length=128) + granted_scopes: frozenset[str] + + +class SourceCitation(ContractModel): + system: str = Field(min_length=1, max_length=64) + url: AnyUrl + revision: str = Field(min_length=1, max_length=256) + retrieved_at: datetime + + +@dataclass(frozen=True) +class DispatchResult: + ok: bool + payload: dict[str, Any] + + +class PolicyDecisionPoint(Protocol): + def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool: ... + + +class CredentialResolver(Protocol): + def resolve(self, identity: IdentityContext, tool_name: str) -> Any: ... + + +@dataclass(frozen=True) +class ProjectContextRuntime: + identity: IdentityContext + policy: PolicyDecisionPoint + credential_resolver: CredentialResolver + + +class ProviderError(RuntimeError): + """A provider failure with a caller-safe message and retry classification.""" + + def __init__(self, code: str, message: str, *, retryable: bool) -> None: + super().__init__(message) + self.code = code + self.safe_message = message + self.retryable = retryable + + +ToolHandler = Callable[[ContractModel, Any], dict[str, Any]] + + +@dataclass(frozen=True) +class ToolTemplate: + name: str + description: str + input_model: type[ContractModel] + output_model: type[ContractModel] + handler: ToolHandler + + def declaration(self) -> dict[str, Any]: + return { + "name": self.name, + "description": self.description, + "inputSchema": self.input_model.model_json_schema(), + "outputSchema": self.output_model.model_json_schema(), + } + + +def error_result( + code: str, + *, + category: str, + retryable: bool, + message: str, + suggested_action: str, + correlation_id: str, +) -> DispatchResult: + return DispatchResult( + ok=False, + payload={ + "error": { + "code": code, + "category": category, + "retryable": retryable, + "message": message, + "suggested_action": suggested_action, + "correlation_id": correlation_id, + } + }, + ) diff --git a/mcp_servers/project_context/providers/__init__.py b/mcp_servers/project_context/providers/__init__.py new file mode 100644 index 0000000..d0dc300 --- /dev/null +++ b/mcp_servers/project_context/providers/__init__.py @@ -0,0 +1 @@ +"""One provider module per member-owned tool work package.""" diff --git a/mcp_servers/project_context/providers/change.py b/mcp_servers/project_context/providers/change.py new file mode 100644 index 0000000..9464363 --- /dev/null +++ b/mcp_servers/project_context/providers/change.py @@ -0,0 +1,25 @@ +"""Provider boundary owned with get_project_change_context.""" + +from __future__ import annotations + +from typing import Any, Protocol + +from ..foundation import IdentityContext, ProviderError + + +class ChangeProvider(Protocol): + def get_change_context(self, **arguments: Any) -> dict[str, Any]: ... + + +class UnconfiguredChangeProvider: + def get_change_context(self, **arguments: Any) -> dict[str, Any]: + raise ProviderError( + "UNAVAILABLE", + "The change provider is not configured for this environment.", + retryable=False, + ) + + +def build_provider(identity: IdentityContext) -> ChangeProvider: + """Replace only this factory when wiring the approved read-only Git adapter.""" + return UnconfiguredChangeProvider() diff --git a/mcp_servers/project_context/providers/issue.py b/mcp_servers/project_context/providers/issue.py new file mode 100644 index 0000000..665db9b --- /dev/null +++ b/mcp_servers/project_context/providers/issue.py @@ -0,0 +1,25 @@ +"""Provider boundary owned with get_project_issue_context.""" + +from __future__ import annotations + +from typing import Any, Protocol + +from ..foundation import IdentityContext, ProviderError + + +class IssueProvider(Protocol): + def get_issue_context(self, **arguments: Any) -> dict[str, Any]: ... + + +class UnconfiguredIssueProvider: + def get_issue_context(self, **arguments: Any) -> dict[str, Any]: + raise ProviderError( + "UNAVAILABLE", + "The issue provider is not configured for this environment.", + retryable=False, + ) + + +def build_provider(identity: IdentityContext) -> IssueProvider: + """Replace only this factory when wiring the approved read-only issue adapter.""" + return UnconfiguredIssueProvider() diff --git a/mcp_servers/project_context/providers/knowledge.py b/mcp_servers/project_context/providers/knowledge.py new file mode 100644 index 0000000..33b088a --- /dev/null +++ b/mcp_servers/project_context/providers/knowledge.py @@ -0,0 +1,25 @@ +"""Provider boundary owned with search_project_knowledge.""" + +from __future__ import annotations + +from typing import Any, Protocol + +from ..foundation import IdentityContext, ProviderError + + +class KnowledgeProvider(Protocol): + def search_knowledge(self, **arguments: Any) -> dict[str, Any]: ... + + +class UnconfiguredKnowledgeProvider: + def search_knowledge(self, **arguments: Any) -> dict[str, Any]: + raise ProviderError( + "UNAVAILABLE", + "The knowledge provider is not configured for this environment.", + retryable=False, + ) + + +def build_provider(identity: IdentityContext) -> KnowledgeProvider: + """Replace only this factory when wiring approved project retrieval.""" + return UnconfiguredKnowledgeProvider() diff --git a/mcp_servers/project_context/registry.py b/mcp_servers/project_context/registry.py new file mode 100644 index 0000000..bd6a78c --- /dev/null +++ b/mcp_servers/project_context/registry.py @@ -0,0 +1,23 @@ +"""Immutable registry composed before member work starts to prevent merge conflicts.""" + +from __future__ import annotations + +from types import MappingProxyType +from typing import Any + +from .foundation import ToolTemplate +from .tools.change_context import TOOL as CHANGE_CONTEXT_TOOL +from .tools.issue_context import TOOL as ISSUE_CONTEXT_TOOL +from .tools.knowledge_search import TOOL as KNOWLEDGE_SEARCH_TOOL + +TOOLS: tuple[ToolTemplate, ...] = ( + ISSUE_CONTEXT_TOOL, + KNOWLEDGE_SEARCH_TOOL, + CHANGE_CONTEXT_TOOL, +) +TOOLS_BY_NAME = MappingProxyType({tool.name: tool for tool in TOOLS}) +TOOL_NAMES = tuple(tool.name for tool in TOOLS) + + +def tool_declarations() -> list[dict[str, Any]]: + return [tool.declaration() for tool in TOOLS] diff --git a/mcp_servers/project_context/runtime.py b/mcp_servers/project_context/runtime.py new file mode 100644 index 0000000..99b528e --- /dev/null +++ b/mcp_servers/project_context/runtime.py @@ -0,0 +1,74 @@ +"""Fail-closed identity, policy, and provider resolution for the template server.""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from .foundation import IdentityContext, ProjectContextRuntime, ProviderError +from .providers.change import build_provider as build_change_provider +from .providers.issue import build_provider as build_issue_provider +from .providers.knowledge import build_provider as build_knowledge_provider + +MINIMUM_PYTHON = (3, 11) + + +def require_supported_python(version_info: tuple[int, ...] | None = None) -> None: + """Fail with an actionable message before the MCP server starts.""" + current = version_info or tuple(sys.version_info[:3]) + if current[:2] < MINIMUM_PYTHON: + raise RuntimeError( + "Project Context MCP requires Python 3.11 or newer; " + f"current runtime is {current[0]}.{current[1]}" + ) + + +@dataclass(frozen=True) +class ProjectScopePolicy: + """Pilot policy: read scope and exact identity-bound project are both mandatory.""" + + def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool: + return "read" in identity.granted_scopes and project_id == identity.project + + +PROVIDER_FACTORIES: dict[str, Callable[[IdentityContext], Any]] = { + "get_project_issue_context": build_issue_provider, + "search_project_knowledge": build_knowledge_provider, + "get_project_change_context": build_change_provider, +} + + +@dataclass(frozen=True) +class ProjectProviderResolver: + def resolve(self, identity: IdentityContext, tool_name: str) -> Any: + factory = PROVIDER_FACTORIES.get(tool_name) + if factory is None: + raise ProviderError("NOT_FOUND", "The requested tool is not registered.", retryable=False) + return factory(identity) + + +def _required_environment(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise RuntimeError(f"Project Context MCP cannot start: required setting {name} is missing") + return value + + +def default_runtime() -> ProjectContextRuntime: + """Build immutable runtime state; missing identity configuration fails at boot.""" + require_supported_python() + identity = IdentityContext( + actor_id=_required_environment("COWORK_MCP_ACTOR_ID"), + org_unit=_required_environment("COWORK_MCP_ORG_UNIT"), + customer=_required_environment("COWORK_MCP_CUSTOMER"), + project=_required_environment("COWORK_MCP_PROJECT"), + granted_scopes=frozenset({"read"}), + ) + return ProjectContextRuntime( + identity=identity, + policy=ProjectScopePolicy(), + credential_resolver=ProjectProviderResolver(), + ) diff --git a/mcp_servers/project_context/server.py b/mcp_servers/project_context/server.py new file mode 100644 index 0000000..0f83616 --- /dev/null +++ b/mcp_servers/project_context/server.py @@ -0,0 +1,142 @@ +"""Low-level MCP stdio adapter around the transport-agnostic Project Context core.""" + +# ruff: noqa: UP045 -- Optional keeps the template importable with Pydantic on Python 3.9. + +from __future__ import annotations + +import json +from typing import Any, Optional +from uuid import uuid4 + +from pydantic import ValidationError + +from .foundation import ( + DispatchResult, + ProjectContextRuntime, + ProviderError, + error_result, +) +from .registry import TOOLS_BY_NAME, tool_declarations +from .runtime import default_runtime, require_supported_python + + +def dispatch( + name: str, + arguments: dict[str, Any], + runtime: ProjectContextRuntime, +) -> DispatchResult: + """Validate → authorize → resolve provider → execute → validate output.""" + correlation_id = str(uuid4()) + tool = TOOLS_BY_NAME.get(name) + if tool is None: + return error_result( + "NOT_FOUND", + category="NOT_FOUND", + retryable=False, + message="The requested MCP tool is not registered.", + suggested_action="Refresh the tool list and choose one of the advertised tools.", + correlation_id=correlation_id, + ) + + try: + validated_input = tool.input_model.model_validate(arguments or {}) + except ValidationError: + return error_result( + "INVALID_INPUT", + category="INVALID_INPUT", + retryable=False, + message="The tool arguments do not match the published input contract.", + suggested_action="Correct the required fields and value bounds, then call again.", + correlation_id=correlation_id, + ) + + project_id = str(validated_input.project_id) + if not runtime.policy.decide(runtime.identity, name, project_id): + return error_result( + "DENIED", + category="DENIED", + retryable=False, + message="The project is outside the caller's approved scope.", + suggested_action="Use an approved project or ask the project owner for access.", + correlation_id=correlation_id, + ) + + try: + provider = runtime.credential_resolver.resolve(runtime.identity, name) + raw_output = tool.handler(validated_input, provider) + except ProviderError as exc: + return error_result( + exc.code, + category=exc.code, + retryable=exc.retryable, + message=exc.safe_message, + suggested_action="Check the approved provider configuration and retry if allowed.", + correlation_id=correlation_id, + ) + except Exception: # noqa: BLE001 - provider failures must not crash or leak into the agent turn + return error_result( + "UPSTREAM_ERROR", + category="UPSTREAM_ERROR", + retryable=False, + message="The approved provider could not complete the request.", + suggested_action="Check the correlation ID in server logs; do not resend credentials.", + correlation_id=correlation_id, + ) + + try: + output_with_trace = {**raw_output, "correlation_id": correlation_id} + validated_output = tool.output_model.model_validate(output_with_trace) + except ValidationError: + return error_result( + "UPSTREAM_ERROR", + category="UPSTREAM_ERROR", + retryable=False, + message="The provider response did not match the published output contract.", + suggested_action="Fix the provider mapping before retrying the request.", + correlation_id=correlation_id, + ) + return DispatchResult(ok=True, payload=validated_output.model_dump(mode="json")) + + +def build_server(runtime: Optional[ProjectContextRuntime] = None): + from mcp import types + from mcp.server.lowlevel import Server + + require_supported_python() + app_runtime = runtime or default_runtime() + app = Server("project_context") + + @app.list_tools() + async def list_tools() -> list[types.Tool]: + return [types.Tool(**declaration) for declaration in tool_declarations()] + + @app.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult: + result = dispatch(name, arguments or {}, app_runtime) + return types.CallToolResult( + content=[types.TextContent( + type="text", + text=json.dumps(result.payload, ensure_ascii=False, separators=(",", ":")), + )], + structuredContent=result.payload if result.ok else None, + isError=not result.ok, + ) + + return app + + +def main() -> None: + import anyio + from mcp.server.stdio import stdio_server + + app = build_server() + + async def _run() -> None: + async with stdio_server() as (read, write): + await app.run(read, write, app.create_initialization_options()) + + anyio.run(_run) + + +if __name__ == "__main__": + main() diff --git a/mcp_servers/project_context/tools/__init__.py b/mcp_servers/project_context/tools/__init__.py new file mode 100644 index 0000000..7ad16e1 --- /dev/null +++ b/mcp_servers/project_context/tools/__init__.py @@ -0,0 +1 @@ +"""Independent tool modules; ownership is documented in the team guide.""" diff --git a/mcp_servers/project_context/tools/change_context.py b/mcp_servers/project_context/tools/change_context.py new file mode 100644 index 0000000..37d5ab2 --- /dev/null +++ b/mcp_servers/project_context/tools/change_context.py @@ -0,0 +1,55 @@ +"""Member C work package: get_project_change_context.""" + +# ruff: noqa: UP045 -- Optional keeps Pydantic model evaluation compatible with Python 3.9. + +from __future__ import annotations + +from typing import Any, Literal, Optional + +from pydantic import Field + +from ..foundation import ContractModel, SourceCitation, ToolTemplate + + +class ChangeContextInput(ContractModel): + project_id: str = Field(min_length=1, max_length=128) + change_id: str = Field(min_length=1, max_length=128) + detail: Literal["summary", "standard", "full"] = "standard" + cursor: Optional[str] = Field(default=None, max_length=2048) + + +class ChangeContextOutput(ContractModel): + correlation_id: str + project_id: str + change_id: str + change_type: Literal["commit", "pull-request", "merge-request"] + title: str + state: str + summary: str + authors: tuple[str, ...] + files: tuple[str, ...] + commits: tuple[str, ...] + related_issues: tuple[str, ...] + source: SourceCitation + truncated: bool + returned: int = Field(ge=0) + remaining: int = Field(ge=0) + next_cursor: Optional[str] = None + + +def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]: + request = ChangeContextInput.model_validate(arguments) + return provider.get_change_context(**request.model_dump()) + + +TOOL = ToolTemplate( + name="get_project_change_context", + description=( + "Returns provider-neutral context for one authorized commit, pull request, or merge request " + "with changed files, commits, related issues, and a pinned source. Use when an exact change " + "identifier is known. Do not use for issue details or free-text document search." + ), + input_model=ChangeContextInput, + output_model=ChangeContextOutput, + handler=_handle, +) diff --git a/mcp_servers/project_context/tools/issue_context.py b/mcp_servers/project_context/tools/issue_context.py new file mode 100644 index 0000000..ed32f6e --- /dev/null +++ b/mcp_servers/project_context/tools/issue_context.py @@ -0,0 +1,59 @@ +"""Member A work package: get_project_issue_context.""" + +# ruff: noqa: UP045 -- Optional keeps Pydantic model evaluation compatible with Python 3.9. + +from __future__ import annotations + +from typing import Any, Literal, Optional + +from pydantic import Field + +from ..foundation import ContractModel, SourceCitation, ToolTemplate + + +class IssueContextInput(ContractModel): + project_id: str = Field(min_length=1, max_length=128) + issue_key: str = Field(min_length=1, max_length=128) + detail: Literal["summary", "standard", "full"] = "standard" + cursor: Optional[str] = Field(default=None, max_length=2048) + + +class RelatedItem(ContractModel): + item_id: str + relation: str + title: str + url: str + + +class IssueContextOutput(ContractModel): + correlation_id: str + project_id: str + issue_key: str + title: str + status: str + description: str + acceptance_criteria: tuple[str, ...] + related: tuple[RelatedItem, ...] + source: SourceCitation + truncated: bool + returned: int = Field(ge=0) + remaining: int = Field(ge=0) + next_cursor: Optional[str] = None + + +def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]: + request = IssueContextInput.model_validate(arguments) + return provider.get_issue_context(**request.model_dump()) + + +TOOL = ToolTemplate( + name="get_project_issue_context", + description=( + "Returns one authorized work item's title, state, description, acceptance criteria, " + "related items, and pinned source. Use when an exact issue key is known. Do not use for " + "free-text knowledge search or Git change review." + ), + input_model=IssueContextInput, + output_model=IssueContextOutput, + handler=_handle, +) diff --git a/mcp_servers/project_context/tools/knowledge_search.py b/mcp_servers/project_context/tools/knowledge_search.py new file mode 100644 index 0000000..3eed718 --- /dev/null +++ b/mcp_servers/project_context/tools/knowledge_search.py @@ -0,0 +1,58 @@ +"""Member B work package: search_project_knowledge.""" + +# ruff: noqa: UP045 -- Optional keeps Pydantic model evaluation compatible with Python 3.9. + +from __future__ import annotations + +from typing import Any, Literal, Optional + +from pydantic import Field + +from ..foundation import ContractModel, SourceCitation, ToolTemplate + + +class KnowledgeSearchInput(ContractModel): + project_id: str = Field(min_length=1, max_length=128) + query: str = Field(min_length=2, max_length=1000) + detail: Literal["summary", "standard", "full"] = "standard" + top_k: int = Field(default=5, ge=1, le=20) + language: Optional[Literal["en", "ja", "vi"]] = None + cursor: Optional[str] = Field(default=None, max_length=2048) + + +class KnowledgeItem(ContractModel): + document_id: str + chunk_id: str + title: str + excerpt: str + score: float = Field(ge=0, le=1) + source: SourceCitation + + +class KnowledgeSearchOutput(ContractModel): + correlation_id: str + project_id: str + query: str + items: tuple[KnowledgeItem, ...] + truncated: bool + returned: int = Field(ge=0) + remaining: int = Field(ge=0) + next_cursor: Optional[str] = None + + +def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]: + request = KnowledgeSearchInput.model_validate(arguments) + return provider.search_knowledge(**request.model_dump()) + + +TOOL = ToolTemplate( + name="search_project_knowledge", + description=( + "Searches approved knowledge for one authorized project and returns ranked excerpts with " + "pinned citations. Use for requirements, design notes, or runbooks when no exact issue is " + "known. Do not use for issue details or Git change review." + ), + input_model=KnowledgeSearchInput, + output_model=KnowledgeSearchOutput, + handler=_handle, +) diff --git a/mcp_servers/project_context_server.py b/mcp_servers/project_context_server.py new file mode 100644 index 0000000..157fff7 --- /dev/null +++ b/mcp_servers/project_context_server.py @@ -0,0 +1,9 @@ +"""Stable module entry point for ``python -m cowork_local.mcp_servers.project_context_server``.""" + +from .project_context.server import build_server, dispatch, main + +__all__ = ["build_server", "dispatch", "main"] + + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..46e4d53 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +"""Make the repository package importable when pytest runs from the repo root.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +REPOSITORY_PARENT = Path(__file__).resolve().parents[2] +if str(REPOSITORY_PARENT) not in sys.path: + sys.path.insert(0, str(REPOSITORY_PARENT)) diff --git a/tests/test_project_context_mcp_template.py b/tests/test_project_context_mcp_template.py new file mode 100644 index 0000000..d5f0ae2 --- /dev/null +++ b/tests/test_project_context_mcp_template.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest +from cowork_local.mcp_servers.project_context.foundation import ( + IdentityContext, + ProjectContextRuntime, +) +from cowork_local.mcp_servers.project_context.registry import ( + TOOL_NAMES, + tool_declarations, +) +from cowork_local.mcp_servers.project_context.runtime import require_supported_python +from cowork_local.mcp_servers.project_context.server import dispatch +from mcp import types + +EXPECTED_TOOLS = { + "get_project_issue_context", + "search_project_knowledge", + "get_project_change_context", +} + + +@dataclass +class RecordingPolicy: + allowed: bool + calls: int = 0 + + def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool: + self.calls += 1 + return self.allowed + + +@dataclass +class RecordingResolver: + provider: Any + calls: int = 0 + + def resolve(self, identity: IdentityContext, tool_name: str) -> Any: + self.calls += 1 + return self.provider + + +@dataclass(frozen=True) +class FakeProvider: + response: dict[str, Any] + + def get_issue_context(self, **_: Any) -> dict[str, Any]: + return dict(self.response) + + def search_knowledge(self, **_: Any) -> dict[str, Any]: + return dict(self.response) + + def get_change_context(self, **_: Any) -> dict[str, Any]: + return dict(self.response) + + +@pytest.fixture +def identity() -> IdentityContext: + return IdentityContext( + actor_id="member-a", + org_unit="fsg", + customer="internal", + project="cowork-local", + granted_scopes=frozenset({"read"}), + ) + + +def runtime(identity: IdentityContext, response: dict[str, Any], *, allowed: bool = True): + policy = RecordingPolicy(allowed=allowed) + resolver = RecordingResolver(provider=FakeProvider(response)) + return ProjectContextRuntime( + identity=identity, + policy=policy, + credential_resolver=resolver, + ), policy, resolver + + +def source() -> dict[str, str]: + return { + "system": "gitea", + "url": "http://example.test/gitea-admin/cowork-local/issues/1", + "revision": "main@abc123", + "retrieved_at": "2026-08-20T10:00:00Z", + } + + +def test_template_exposes_exactly_three_provider_neutral_tools() -> None: + assert set(TOOL_NAMES) == EXPECTED_TOOLS + declarations = tool_declarations() + assert {item["name"] for item in declarations} == EXPECTED_TOOLS + assert all(item["inputSchema"]["additionalProperties"] is False for item in declarations) + assert all(item["outputSchema"]["additionalProperties"] is False for item in declarations) + assert all(types.Tool(**item).name in EXPECTED_TOOLS for item in declarations) + + +def test_runtime_fails_fast_below_python_311() -> None: + with pytest.raises(RuntimeError, match="requires Python 3.11"): + require_supported_python((3, 9, 0)) + + +def test_denied_request_never_resolves_credentials_or_calls_provider( + identity: IdentityContext, +) -> None: + app, policy, resolver = runtime(identity, {}, allowed=False) + + result = dispatch( + "get_project_issue_context", + {"project_id": "other-project", "issue_key": "1"}, + app, + ) + + assert result.ok is False + assert result.payload["error"]["code"] == "DENIED" + assert policy.calls == 1 + assert resolver.calls == 0 + + +def test_invalid_input_is_rejected_before_policy(identity: IdentityContext) -> None: + app, policy, resolver = runtime(identity, {}) + + result = dispatch("get_project_issue_context", {"project_id": "cowork-local"}, app) + + assert result.ok is False + assert result.payload["error"]["code"] == "INVALID_INPUT" + assert policy.calls == 0 + assert resolver.calls == 0 + + +@pytest.mark.parametrize( + ("tool_name", "arguments", "response"), + [ + ( + "get_project_issue_context", + {"project_id": "cowork-local", "issue_key": "1"}, + { + "project_id": "cowork-local", + "issue_key": "1", + "title": "MCP pilot", + "status": "open", + "description": "Build verifiable project context.", + "acceptance_criteria": ["Every result has a source."], + "related": [], + "source": source(), + "truncated": False, + "returned": 1, + "remaining": 0, + "next_cursor": None, + }, + ), + ( + "search_project_knowledge", + {"project_id": "cowork-local", "query": "MCP setup"}, + { + "project_id": "cowork-local", + "query": "MCP setup", + "items": [ + { + "document_id": "README.md", + "chunk_id": "README.md#setup", + "title": "Setup", + "excerpt": "Install the approved dependencies.", + "score": 0.9, + "source": source(), + } + ], + "truncated": False, + "returned": 1, + "remaining": 0, + "next_cursor": None, + }, + ), + ( + "get_project_change_context", + {"project_id": "cowork-local", "change_id": "1"}, + { + "project_id": "cowork-local", + "change_id": "1", + "change_type": "pull-request", + "title": "Add MCP contract", + "state": "merged", + "summary": "Introduces the project context contract.", + "authors": ["member-c"], + "files": ["mcp/contract.yaml"], + "commits": ["abc123"], + "related_issues": ["1"], + "source": source(), + "truncated": False, + "returned": 1, + "remaining": 0, + "next_cursor": None, + }, + ), + ], +) +def test_each_member_template_has_a_valid_success_path( + identity: IdentityContext, + tool_name: str, + arguments: dict[str, Any], + response: dict[str, Any], +) -> None: + app, policy, resolver = runtime(identity, response) + + result = dispatch(tool_name, arguments, app) + + assert result.ok is True + assert result.payload["project_id"] == "cowork-local" + assert result.payload["correlation_id"] + assert policy.calls == 1 + assert resolver.calls == 1 + + +def test_provider_output_must_match_contract(identity: IdentityContext) -> None: + app, _, _ = runtime(identity, {"project_id": "cowork-local"}) + + result = dispatch( + "get_project_issue_context", + {"project_id": "cowork-local", "issue_key": "1"}, + app, + ) + + assert result.ok is False + assert result.payload["error"]["code"] == "UPSTREAM_ERROR" + + +def test_unexpected_provider_error_does_not_leak_exception(identity: IdentityContext) -> None: + class LeakingProvider: + def get_issue_context(self, **_: Any) -> dict[str, Any]: + raise RuntimeError("secret provider-token-value") + + policy = RecordingPolicy(allowed=True) + resolver = RecordingResolver(provider=LeakingProvider()) + app = ProjectContextRuntime(identity=identity, policy=policy, credential_resolver=resolver) + + result = dispatch( + "get_project_issue_context", + {"project_id": "cowork-local", "issue_key": "1"}, + app, + ) + + assert result.ok is False + assert result.payload["error"]["code"] == "UPSTREAM_ERROR" + assert "secret" not in str(result.payload)