Merge pull request 'feat(mcp): scaffold three project context tools' (#4) from codex/project-context-mcp-template into main
CI / test (push) Canceled after 0s
CI / test (push) Canceled after 0s
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -105,7 +105,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
|||||||
# sandboxes agent-run shell commands) — reading a URL for info is safe and
|
# sandboxes agent-run shell commands) — reading a URL for info is safe and
|
||||||
# useful, so this defaults ON. Toggle in Settings → Security.
|
# useful, so this defaults ON. Toggle in Settings → Security.
|
||||||
"allow_url_fetch": True,
|
"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
|
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
|
||||||
},
|
},
|
||||||
# Legacy generic-MCP-server list. MERGED into ext_connectors["other"] as of
|
# 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
|
# Microsoft. Real Outlook/Teams/OneDrive/SharePoint access still requires a
|
||||||
# proper OAuth sign-in (not implemented yet) using tenant_id/client_id below.
|
# proper OAuth sign-in (not implemented yet) using tenant_id/client_id below.
|
||||||
"ms365": {
|
"ms365": {
|
||||||
"unlock_code": "quandh14",
|
"unlock_code": "", # set through COWORK_MS365_UNLOCK_CODE
|
||||||
"unlocked": False, # runtime-only — never persisted as True, see save()
|
"unlocked": False, # runtime-only — never persisted as True, see save()
|
||||||
# Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server
|
# Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server
|
||||||
# launches automatically once the user is signed in (OAuth tenant/client
|
# 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"]
|
data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"]
|
||||||
if os.getenv("COWORK_CA_BUNDLE"):
|
if os.getenv("COWORK_CA_BUNDLE"):
|
||||||
data["tls_ca_bundle"] = os.environ["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
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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/<tool>.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_<tool>.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=<actor> \
|
||||||
|
COWORK_MCP_ORG_UNIT=<org> \
|
||||||
|
COWORK_MCP_CUSTOMER=<customer> \
|
||||||
|
COWORK_MCP_PROJECT=<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`.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Provider-neutral Project Context MCP server template."""
|
||||||
|
|
||||||
|
from .server import build_server, dispatch
|
||||||
|
|
||||||
|
__all__ = ["build_server", "dispatch"]
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""One provider module per member-owned tool work package."""
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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]
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Independent tool modules; ownership is documented in the team guide."""
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user