Files
GEWU/src/gewu/transports/mcp_server.py
T

1254 lines
54 KiB
Python
Raw Normal View History

"""Authenticated MCP read and contribution operations."""
from __future__ import annotations
import base64
import binascii
import hashlib
import json
import re
from collections.abc import Callable, Iterator
from datetime import UTC, date, datetime
from typing import Literal
from urllib.parse import urlsplit, urlunsplit
import portalocker
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from pydantic import ValidationError
from gewu.adapters.pdf_extractor import PdfExtractionError
from gewu.adapters.source_extractor import SourceExtractor
from gewu.adapters.url_fetch import PublicUrlFetcher, UrlCaptureError
from gewu.application.acceptance import AcceptanceError, AcceptanceService, ActorContext
from gewu.application.context import ValidationContext
from gewu.application.diagnostics import build_service_diagnostics
from gewu.application.identity import IdentityProvider, Principal
from gewu.application.reading import CanonicalNodeReader, CanonicalSourceReader, ReadError
from gewu.application.runs import LocalRunService, RunError
from gewu.application.search import CanonicalSearchService, SearchError
from gewu.application.uploads import (
CHUNK_BYTES,
LocalArtifactStore,
UploadError,
)
from gewu.domain.contracts import (
Confidence,
EvidenceReference,
ExtractionRecord,
GitCommitId,
NeighborRequest,
NodeType,
ProjectContextResponse,
ProjectLens,
ProposalRevision,
ReliabilityGrade,
RunCheckpointRequest,
RunCreateRequest,
Scope,
SearchRequest,
SourceListRequest,
SourceRevisionRecord,
)
from gewu.domain.validation import validate_proposal
from gewu.version import SERVICE_VERSION
MAX_PASTED_TEXT_BYTES = 1_000_000
MAX_PROJECT_CONTEXT_BYTES = 64_000
def _principal(ctx: Context, identities: IdentityProvider) -> Principal:
headers = ctx.headers or {}
authorization = next(
(value for key, value in headers.items() if key.lower() == "authorization"), ""
)
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
raise PermissionError("A verified GEWU bearer credential is required")
principal = identities.authenticate(token)
if principal is None:
raise PermissionError("GEWU credential is invalid or revoked")
return principal
def create_mcp_server(
*,
identity_provider: IdentityProvider,
node_reader: CanonicalNodeReader,
source_reader: CanonicalSourceReader | None = None,
search_service: CanonicalSearchService | None = None,
artifact_store: LocalArtifactStore | None = None,
extractor: SourceExtractor | None = None,
acceptance_service: AcceptanceService | None = None,
context_provider: Callable[[], ValidationContext] | None = None,
url_fetcher: PublicUrlFetcher | None = None,
) -> MCPServer:
"""Create the allowlisted MCP server for the supplied application services."""
if url_fetcher is None:
url_fetcher = PublicUrlFetcher(frozenset())
server = MCPServer(
"GEWU",
version=SERVICE_VERSION,
instructions="Read accepted GEWU knowledge by explicit project and node ID.",
)
available_operations = {"diagnostics", "get_node", "node_history"}
available_operations.add("source_capture_url")
@server.tool(name="diagnostics", structured_output=False)
def diagnostics(
ctx: Context,
projects: list[str] | None = None,
include_shared: bool = False,
) -> str:
"""Report authenticated caller scope, service contracts, limits and index state."""
principal = _principal(ctx, identity_provider)
authorized_projects = (
search_service.list_projects(principal=principal)
if search_service is not None
else tuple(
sorted(
project
for project in principal.project_ids
if principal.can_read_project(project)
)
)
)
index_status = (
search_service.diagnostic_index_status()
if search_service is not None
else (node_reader.repository.head(), None, "not_configured", None)
)
try:
report = build_service_diagnostics(
principal=principal,
transport="mcp",
authorized_projects=tuple(authorized_projects),
available_operations=frozenset(available_operations),
index_status=index_status,
projects=tuple(projects) if projects is not None else None,
include_shared=include_shared,
)
except (PermissionError, ValueError) as error:
raise ValueError(str(error)) from error
return json.dumps(report.model_dump(mode="json"), ensure_ascii=False)
@server.tool(name="get_node", structured_output=False)
def get_node(
project_id: str,
node_id: str,
ctx: Context,
revision: GitCommitId | None = None,
) -> str:
"""Read an accepted node revision and its exact evidence citations."""
principal = _principal(ctx, identity_provider)
try:
envelope = node_reader.get_node(
project_id, node_id, principal=principal, revision=revision
)
except ReadError as error:
raise ValueError(str(error)) from error
return json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False)
@server.tool(name="node_history", structured_output=False)
def node_history(project_id: str, node_id: str, ctx: Context, limit: int = 20) -> str:
"""Read bounded accepted revisions of one project-scoped node."""
principal = _principal(ctx, identity_provider)
try:
envelope = node_reader.get_history(
project_id, node_id, principal=principal, limit=limit
)
except ReadError as error:
raise ValueError(str(error)) from error
return json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False)
if source_reader is not None:
available_operations.add("get_source")
@server.tool(name="get_source", structured_output=False)
def get_source(project_id: str, revision_id: str, ctx: Context) -> str:
"""Read a source revision only through one of its ingested_for grants."""
principal = _principal(ctx, identity_provider)
try:
envelope = source_reader.get_source(project_id, revision_id, principal=principal)
except ReadError as error:
raise ValueError(str(error)) from error
return json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False)
if search_service is not None:
available_operations.update({"list_projects", "search", "neighbors", "list_sources"})
@server.tool(name="list_projects", structured_output=False)
def list_projects(ctx: Context) -> str:
"""List project IDs readable by the authenticated principal."""
principal = _principal(ctx, identity_provider)
return json.dumps(search_service.list_projects(principal=principal))
@server.tool(name="search", structured_output=False)
def search(
query: str,
projects: list[str],
include_shared: bool,
all_projects: bool,
limit: int,
ctx: Context,
budget_tokens: int,
cursor: str | None = None,
include_projects: list[str] | None = None,
node_types: list[NodeType] | None = None,
tags: list[str] | None = None,
audience: list[str] | None = None,
use_for: list[str] | None = None,
domain: list[str] | None = None,
confidences: list[Confidence] | None = None,
as_of_from: str | None = None,
as_of_to: str | None = None,
stale_only: bool = False,
) -> str:
"""Search accepted knowledge in an explicit, authorized project scope."""
principal = _principal(ctx, identity_provider)
try:
request = SearchRequest(
query=query,
scope=Scope(
projects=tuple(projects),
include_projects=(
tuple(include_projects) if include_projects is not None else None
),
include_shared=include_shared,
all_projects=all_projects,
),
limit=limit,
node_types=tuple(node_types or ()),
tags=tuple(tags or ()),
audience=tuple(audience or ()),
use_for=tuple(use_for or ()),
domain=tuple(domain or ()),
confidences=tuple(confidences or ()),
as_of_from=date.fromisoformat(as_of_from) if as_of_from else None,
as_of_to=date.fromisoformat(as_of_to) if as_of_to else None,
stale_only=stale_only,
cursor=cursor,
budget_tokens=budget_tokens,
)
envelope = search_service.search(request, principal=principal)
except SearchError as error:
raise ValueError(str(error)) from error
except ValueError as error:
raise ValueError("Search request failed validation") from error
return json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False)
@server.tool(name="list_sources", structured_output=False)
def list_sources(
projects: list[str],
include_shared: bool,
all_projects: bool,
limit: int,
ctx: Context,
budget_tokens: int,
cursor: str | None = None,
include_projects: list[str] | None = None,
) -> str:
"""List source revisions granted to an explicit authorized project scope."""
principal = _principal(ctx, identity_provider)
try:
request = SourceListRequest(
scope=Scope(
projects=tuple(projects),
include_projects=(
tuple(include_projects) if include_projects is not None else None
),
include_shared=include_shared,
all_projects=all_projects,
),
limit=limit,
cursor=cursor,
budget_tokens=budget_tokens,
)
envelope = search_service.list_sources(request, principal=principal)
except SearchError as error:
raise ValueError(str(error)) from error
except ValueError as error:
raise ValueError("Source list request failed validation") from error
return json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False)
@server.tool(name="neighbors", structured_output=False)
def neighbors(
node_id: str,
projects: list[str],
include_shared: bool,
all_projects: bool,
edge_types: list[str],
limit: int,
ctx: Context,
budget_tokens: int,
cursor: str | None = None,
include_projects: list[str] | None = None,
) -> str:
"""Read scoped typed relationships and their exact citations."""
principal = _principal(ctx, identity_provider)
try:
request = NeighborRequest.model_validate_json(
json.dumps(
{
"node_id": node_id,
"scope": {
"projects": projects,
"include_projects": include_projects,
"include_shared": include_shared,
"all_projects": all_projects,
},
"edge_types": edge_types,
"limit": limit,
"cursor": cursor,
"budget_tokens": budget_tokens,
}
),
strict=True,
)
envelope = search_service.neighbors(request, principal=principal)
except SearchError as error:
raise ValueError(str(error)) from error
except ValueError as error:
raise ValueError("Neighbor request failed validation") from error
return json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False)
contribution_services = (extractor, acceptance_service, context_provider)
if any(service is not None for service in contribution_services) and (
artifact_store is None or any(service is None for service in contribution_services)
):
raise ValueError(
"Contribution tools require artifact, extraction, context and acceptance services"
)
if (
artifact_store is not None
and extractor is not None
and acceptance_service is not None
and context_provider is not None
):
available_operations.update(
{
"source_upload_begin",
"source_upload_status",
"source_upload_chunk",
"source_upload_finalize",
"source_capture_text",
"source_extract",
"source_extraction_block",
"proposal_evaluate",
"proposal_submit",
"proposal_status",
"proposal_withdraw",
"run_create",
"run_checkpoint",
"run_status",
}
)
run_service = LocalRunService(
workflow_root=acceptance_service.root,
actor_is_current=acceptance_service.actor_is_current,
)
@server.tool(name="source_capture_text", structured_output=False)
def source_capture_text(
capture_id: str,
project_id: str,
source_key: str,
text: str,
ctx: Context,
title: str | None = None,
original_url: str | None = None,
media_type: Literal["text/plain", "text/markdown"] = "text/plain",
source_type: Literal[
"paper", "review", "press", "vendor", "internal", "talk", "export", "other"
] = "other",
reliability_grade: ReliabilityGrade = ReliabilityGrade.LOW,
license: str = "unspecified",
authors: list[str] | None = None,
publisher: str | None = None,
) -> str:
"""Archive explicitly supplied UTF-8 text and return its source candidate."""
principal = _principal(ctx, identity_provider)
_require_project_submission(principal, project_id)
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", capture_id):
raise ValueError("Capture ID has invalid syntax")
if not source_key.strip() or len(source_key) > 500:
raise ValueError("A stable source key between 1 and 500 characters is required")
if len(title or source_key) > 500 or len(license) > 500:
raise ValueError("Source title and license must be at most 500 characters")
if publisher is not None and len(publisher) > 300:
raise ValueError("Source publisher must be at most 300 characters")
if len(authors or ()) > 50 or any(len(author) > 300 for author in authors or ()):
raise ValueError("Source authors exceed the supported count or length")
content = text.encode("utf-8")
if not text.strip() or "\x00" in text or len(content) > MAX_PASTED_TEXT_BYTES:
raise ValueError("Pasted text must be non-empty and at most 1 MB of UTF-8")
safe_url = _redacted_source_url(original_url) if original_url else None
if original_url and not safe_url:
raise ValueError("Original URL is invalid or contains credentials")
digest = hashlib.sha256(content).hexdigest()
request_sha256 = hashlib.sha256(
json.dumps(
{
"project_id": project_id,
"source_key": source_key,
"content_sha256": digest,
"title": title,
"original_url": safe_url,
"media_type": media_type,
"source_type": source_type,
"reliability_grade": reliability_grade.value,
"license": license,
"authors": authors or [],
"publisher": publisher,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
filename = "pasted-source.md" if media_type == "text/markdown" else "pasted-source.txt"
try:
with artifact_store.capture_receipt_guard():
cached = artifact_store.get_capture_receipt(
capture_id,
owner_id=principal.principal_id,
project_id=project_id,
request_sha256=request_sha256,
)
if cached is not None:
return json.dumps(cached, ensure_ascii=False)
session = artifact_store.begin(
filename=filename,
media_type=media_type,
byte_length=len(content),
expected_sha256=digest,
owner_id=principal.principal_id,
project_id=project_id,
)
for offset in range(0, len(content), CHUNK_BYTES):
chunk = content[offset : offset + CHUNK_BYTES]
artifact_store.put_chunk(
session.upload_id,
offset=offset,
content=chunk,
chunk_sha256=hashlib.sha256(chunk).hexdigest(),
owner_id=principal.principal_id,
)
receipt = artifact_store.finalize(
session.upload_id, owner_id=principal.principal_id
)
captured_at = datetime.now(UTC)
source_candidate = _pasted_text_source_candidate(
source_key,
project_id=project_id,
captured_at=captured_at,
title=title,
original_url=safe_url,
artifact_sha256=receipt.sha256,
media_type=receipt.media_type,
byte_length=receipt.byte_length,
source_type=source_type,
reliability_grade=reliability_grade,
license=license,
authors=authors,
publisher=publisher,
)
extraction = extractor.extract(
source_candidate.revision_id, content, media_type
)
artifact_store.save_extraction(
extraction, owner_id=principal.principal_id, project_id=project_id
)
result: dict[str, object] = {
"sha256": receipt.sha256,
"byte_length": receipt.byte_length,
"media_type": receipt.media_type,
"source_candidate": source_candidate.model_dump(mode="json"),
"extraction_id": extraction.extraction_id,
"capture_status": "archived",
}
artifact_store.save_capture_receipt(
capture_id,
owner_id=principal.principal_id,
project_id=project_id,
request_sha256=request_sha256,
result=result,
)
except (UploadError, ValueError) as error:
raise ValueError(str(error)) from error
except portalocker.exceptions.LockException as error:
raise ValueError(
"Pasted-text capture is busy; retry with the same capture ID and inputs"
) from error
return json.dumps(
result,
ensure_ascii=False,
)
@server.tool(name="source_capture_url", structured_output=False)
def source_capture_url(
project_id: str,
url: str,
ctx: Context,
title: str | None = None,
source_type: Literal[
"paper", "review", "press", "vendor", "internal", "talk", "export", "other"
] = "other",
reliability_grade: ReliabilityGrade = ReliabilityGrade.LOW,
license: str = "unspecified",
authors: list[str] | None = None,
publisher: str | None = None,
) -> str:
"""Stage an allowlisted URL and return a source-record proposal candidate."""
principal = _principal(ctx, identity_provider)
_require_project_submission(principal, project_id)
safe_url = _redacted_source_url(url)
if not safe_url:
raise ValueError("Source URL is invalid or contains credentials")
try:
capture = url_fetcher.fetch(url)
except UrlCaptureError as error:
if error.code == "FETCH_BLOCKED":
raise ValueError(str(error)) from error
candidate = _url_source_candidate(
safe_url,
project_id=project_id,
title=title,
source_type=source_type,
reliability_grade=reliability_grade,
license=license,
authors=authors,
publisher=publisher,
archive_status="capture-failed",
)
return json.dumps(
{
"status": "capture_failed",
"error_code": error.code,
"message": str(error),
"source_candidate": candidate.model_dump(mode="json"),
},
ensure_ascii=False,
)
try:
extension = {
"application/pdf": ".pdf",
"text/html": ".html",
"text/markdown": ".md",
"text/plain": ".txt",
}.get(capture.media_type)
if extension is None:
raise UrlCaptureError(
"UNSUPPORTED_MEDIA_TYPE", "URL capture source format is unsupported"
)
candidate_name = urlsplit(capture.final_url).path.rsplit("/", 1)[-1]
filename = (
candidate_name
if candidate_name.lower().endswith(extension)
else f"captured-source{extension}"
)
session = artifact_store.begin(
filename=filename,
media_type=capture.media_type,
byte_length=len(capture.content),
expected_sha256=hashlib.sha256(capture.content).hexdigest(),
owner_id=principal.principal_id,
project_id=project_id,
)
for offset in range(0, len(capture.content), CHUNK_BYTES):
chunk = capture.content[offset : offset + CHUNK_BYTES]
artifact_store.put_chunk(
session.upload_id,
offset=offset,
content=chunk,
chunk_sha256=hashlib.sha256(chunk).hexdigest(),
owner_id=principal.principal_id,
)
receipt = artifact_store.finalize(
session.upload_id, owner_id=principal.principal_id
)
except (UrlCaptureError, UploadError) as error:
candidate = _url_source_candidate(
safe_url,
project_id=project_id,
title=title or capture.title,
source_type=source_type,
reliability_grade=reliability_grade,
license=license,
authors=authors,
publisher=publisher,
archive_status="capture-failed",
final_url=_redacted_source_url(capture.final_url),
)
return json.dumps(
{
"status": "capture_failed",
"error_code": getattr(error, "code", "ARCHIVE_FAILED"),
"message": str(error),
"source_candidate": candidate.model_dump(mode="json"),
},
ensure_ascii=False,
)
source_candidate = _url_source_candidate(
safe_url,
project_id=project_id,
title=title or capture.title,
source_type=source_type,
reliability_grade=reliability_grade,
license=license,
authors=authors,
publisher=publisher,
archive_status="archived",
final_url=_redacted_source_url(capture.final_url),
artifact_sha256=receipt.sha256,
media_type=receipt.media_type,
byte_length=receipt.byte_length,
)
return json.dumps(
{
"capture_status": "archived",
"original_url": _redacted_source_url(capture.original_url),
"final_url": _redacted_source_url(capture.final_url),
"title": capture.title,
"sha256": receipt.sha256,
"byte_length": receipt.byte_length,
"media_type": receipt.media_type,
"artifact_status": receipt.status,
"source_candidate": source_candidate.model_dump(mode="json"),
},
ensure_ascii=False,
)
@server.tool(name="source_upload_begin", structured_output=False)
def source_upload_begin(
project_id: str,
filename: str,
media_type: str,
byte_length: int,
expected_sha256: str,
ctx: Context,
) -> str:
"""Begin a bounded source upload scoped to an authorized project."""
principal = _principal(ctx, identity_provider)
_require_project_submission(principal, project_id)
try:
session = artifact_store.begin(
filename=filename,
media_type=media_type,
byte_length=byte_length,
expected_sha256=expected_sha256,
owner_id=principal.principal_id,
project_id=project_id,
)
except UploadError as error:
raise ValueError(str(error)) from error
return json.dumps(
{
"upload_id": session.upload_id,
"verified_offsets": session.verified_offsets,
"chunk_bytes": session.chunk_bytes,
"max_artifact_bytes": session.max_artifact_bytes,
"expires_at": session.expires_at,
}
)
@server.tool(name="source_upload_status", structured_output=False)
def source_upload_status(upload_id: str, ctx: Context) -> str:
"""Resume an upload owned by the authenticated agent identity."""
principal = _principal(ctx, identity_provider)
try:
offsets = artifact_store.verified_offsets(
upload_id, owner_id=principal.principal_id
)
except UploadError as error:
raise ValueError(str(error)) from error
return json.dumps({"upload_id": upload_id, "verified_offsets": offsets})
@server.tool(name="source_upload_chunk", structured_output=False)
def source_upload_chunk(
upload_id: str,
offset: int,
content_base64: str,
chunk_sha256: str,
ctx: Context,
) -> str:
"""Transfer one integrity-checked upload chunk."""
principal = _principal(ctx, identity_provider)
if len(content_base64) > ((CHUNK_BYTES + 2) // 3) * 4:
raise ValueError("Encoded upload chunk exceeds the supported limit")
try:
content = base64.b64decode(content_base64, validate=True)
offsets = artifact_store.put_chunk(
upload_id,
offset=offset,
content=content,
chunk_sha256=chunk_sha256,
owner_id=principal.principal_id,
)
except (UploadError, binascii.Error) as error:
raise ValueError(str(error)) from error
return json.dumps({"upload_id": upload_id, "verified_offsets": offsets})
@server.tool(name="source_upload_finalize", structured_output=False)
def source_upload_finalize(upload_id: str, ctx: Context) -> str:
"""Finalize and content-address a supported file or UTF-8 transcript artifact."""
principal = _principal(ctx, identity_provider)
try:
receipt = artifact_store.finalize(upload_id, owner_id=principal.principal_id)
except UploadError as error:
raise ValueError(str(error)) from error
return json.dumps(
{
"sha256": receipt.sha256,
"byte_length": receipt.byte_length,
"media_type": receipt.media_type,
"status": receipt.status,
}
)
@server.tool(name="source_extract", structured_output=False)
def source_extract(
project_id: str,
artifact_sha256: str,
source_revision_id: str,
ctx: Context,
) -> str:
"""Create and stage a versioned text extraction for a source revision."""
principal = _principal(ctx, identity_provider)
_require_project_submission(principal, project_id)
try:
with artifact_store.open_artifact(
artifact_sha256,
owner_id=principal.principal_id,
project_id=project_id,
) as stream:
content = stream.read(50 * 1024 * 1024 + 1)
if len(content) > 50 * 1024 * 1024:
raise ValueError("Source exceeds the extraction limit")
media_type = artifact_store.artifact_media_type(
artifact_sha256,
owner_id=principal.principal_id,
project_id=project_id,
)
record = extractor.extract(source_revision_id, content, media_type)
artifact_store.save_extraction(
record, owner_id=principal.principal_id, project_id=project_id
)
except PdfExtractionError as error:
return json.dumps(
{
"source_revision_id": source_revision_id,
"status": "unavailable",
"error_code": error.code,
"warnings": [str(error)],
"blocks": [],
},
ensure_ascii=False,
)
except (UploadError, ValueError) as error:
raise ValueError(str(error)) from error
return json.dumps(
{
"extraction_id": record.extraction_id,
"source_revision_id": record.source_revision_id,
"parser_name": record.parser_name,
"parser_version": record.parser_version,
"status": record.status,
"warnings": record.warnings,
"blocks": [
{
"block_id": block.block_id,
"ordinal": block.ordinal,
"codepoints": len(block.normalized_text),
"page": block.page,
}
for block in record.blocks
],
}
)
@server.tool(name="source_extraction_block", structured_output=False)
def source_extraction_block(
project_id: str,
extraction_id: str,
block_id: str,
offset: int,
length: int,
ctx: Context,
) -> str:
"""Read a bounded normalized text slice and its source offset map."""
principal = _principal(ctx, identity_provider)
_require_project_submission(principal, project_id)
if offset < 0 or length < 1 or length > 12_000:
raise ValueError("Extraction slice is outside the supported limit")
extraction = artifact_store.get_extraction(
extraction_id, owner_id=principal.principal_id, project_id=project_id
)
if extraction is None:
raise ValueError("Staged extraction does not exist")
block = next((item for item in extraction.blocks if item.block_id == block_id), None)
if block is None or offset >= len(block.normalized_text):
raise ValueError("Extraction block or offset does not exist")
end = min(offset + length, len(block.normalized_text))
spans = block.normalization_map[offset:end]
original_start = min(start for start, _ in spans)
original_end = max(stop for _, stop in spans)
return json.dumps(
{
"extraction_id": extraction_id,
"block_id": block_id,
"page": block.page,
"offset": offset,
"normalized_text": block.normalized_text[offset:end],
"original_text": block.original_text[original_start:original_end],
"normalization_map": spans,
},
ensure_ascii=False,
)
@server.tool(name="proposal_submit", structured_output=False)
def proposal_submit(proposal_json: str, ctx: Context) -> str:
"""Validate and submit one bounded typed proposal revision."""
principal = _principal(ctx, identity_provider)
proposal = _parse_proposal(proposal_json)
context = context_provider()
_authorize_proposal(principal, proposal, context)
staged_extractions: dict[str, ExtractionRecord] = {}
for evidence in _proposal_evidence(proposal):
extraction = artifact_store.get_extraction(
evidence.extraction_id,
owner_id=principal.principal_id,
)
if extraction is not None:
staged_extractions[extraction.extraction_id] = extraction
try:
outcome = acceptance_service.submit(
proposal,
actor=ActorContext(
principal.principal_id,
principal.credential_id,
roles=principal.roles,
project_ids=principal.project_ids,
readable_project_ids=_proposal_readable_projects(
principal, proposal, context
),
),
staged_extractions=staged_extractions,
)
except AcceptanceError as error:
raise ValueError(str(error)) from error
result: dict[str, object] = {
"proposal_id": proposal.proposal_id,
"revision": proposal.revision,
"status": "accepted" if outcome.receipt else outcome.validation.status,
"validation_status": outcome.validation.status,
"eligible_for_auto_accept": outcome.validation.eligible_for_auto_accept,
"findings": [
finding.model_dump(mode="json") for finding in outcome.validation.findings
],
"affected_policy_ids": outcome.validation.affected_policy_ids,
"owner_challenge_ids": outcome.owner_challenge_ids,
"receipt": outcome.receipt.model_dump(mode="json") if outcome.receipt else None,
}
return json.dumps(result, ensure_ascii=False)
@server.tool(name="proposal_evaluate", structured_output=False)
def proposal_evaluate(proposal_json: str, ctx: Context) -> str:
"""Evaluate one bounded proposal without storing or publishing it."""
principal = _principal(ctx, identity_provider)
proposal = _parse_proposal(proposal_json)
context = context_provider()
_authorize_proposal(principal, proposal, context)
staged_extractions: dict[str, ExtractionRecord] = {}
for evidence in _proposal_evidence(proposal):
extraction = artifact_store.get_extraction(
evidence.extraction_id,
owner_id=principal.principal_id,
)
if extraction is not None:
staged_extractions[extraction.extraction_id] = extraction
report = validate_proposal(
proposal,
projects=dict(context.projects),
policies=dict(context.policies),
sources=dict(context.sources),
extractions={**context.extractions, **staged_extractions},
existing_nodes=dict(context.existing_nodes),
current_base_commit=acceptance_service.repository.head(),
readable_project_ids=_proposal_readable_projects(principal, proposal, context),
)
return json.dumps(
{
"proposal_id": proposal.proposal_id,
"revision": proposal.revision,
"status": report.status,
"eligible_for_auto_accept": report.eligible_for_auto_accept,
"findings": [finding.model_dump(mode="json") for finding in report.findings],
"affected_policy_ids": report.affected_policy_ids,
},
ensure_ascii=False,
)
@server.tool(name="proposal_status", structured_output=False)
def proposal_status(proposal_id: str, revision: int, ctx: Context) -> str:
"""Read bounded lifecycle for a proposal created by this authenticated agent."""
principal = _principal(ctx, identity_provider)
if "agent" not in principal.roles:
raise PermissionError("Only the proposing agent can read proposal status")
try:
status = acceptance_service.proposal_status(
proposal_id,
revision,
actor=ActorContext(principal.principal_id, principal.credential_id),
)
except AcceptanceError as error:
raise ValueError(str(error)) from error
return json.dumps(status.model_dump(mode="json"), ensure_ascii=False)
@server.tool(name="proposal_withdraw", structured_output=False)
def proposal_withdraw(proposal_id: str, revision: int, ctx: Context) -> str:
"""Withdraw this agent's unpublished proposal under the canonical writer lock."""
principal = _principal(ctx, identity_provider)
if "agent" not in principal.roles:
raise PermissionError("Only a proposing agent can withdraw a proposal")
try:
status = acceptance_service.withdraw_proposal(
proposal_id,
revision,
actor=ActorContext(principal.principal_id, principal.credential_id),
)
except AcceptanceError as error:
raise ValueError(str(error)) from error
return json.dumps(status.model_dump(mode="json"), ensure_ascii=False)
@server.tool(name="run_create", structured_output=False)
def run_create(request_json: str, ctx: Context) -> str:
"""Create or idempotently recover an agent-owned durable run."""
principal = _principal(ctx, identity_provider)
if "agent" not in principal.roles or principal.auth_method != "bearer":
raise PermissionError("Durable runs require an agent bearer credential")
request = _parse_run_request(request_json, RunCreateRequest)
_require_run_scope(principal, request.scope)
try:
record = run_service.create(
request,
actor=ActorContext(principal.principal_id, principal.credential_id),
)
except RunError as error:
raise ValueError(f"{error.code}: {error}") from error
return json.dumps(record.model_dump(mode="json"), ensure_ascii=False)
@server.tool(name="run_checkpoint", structured_output=False)
def run_checkpoint(request_json: str, ctx: Context) -> str:
"""Append an idempotent, revision-checked checkpoint to an owned run."""
principal = _principal(ctx, identity_provider)
if "agent" not in principal.roles or principal.auth_method != "bearer":
raise PermissionError("Durable runs require an agent bearer credential")
request = _parse_run_request(request_json, RunCheckpointRequest)
actor = ActorContext(principal.principal_id, principal.credential_id)
try:
current = run_service.status(request.run_id, actor=actor)
_require_run_scope(principal, current.scope)
record = run_service.checkpoint(request, actor=actor)
except RunError as error:
raise ValueError(f"{error.code}: {error}") from error
return json.dumps(record.model_dump(mode="json"), ensure_ascii=False)
@server.tool(name="run_status", structured_output=False)
def run_status(run_id: str, ctx: Context) -> str:
"""Read the latest durable checkpoint for the authenticated run owner."""
principal = _principal(ctx, identity_provider)
if "agent" not in principal.roles or principal.auth_method != "bearer":
raise PermissionError("Durable runs require an agent bearer credential")
try:
record = run_service.status(
run_id,
actor=ActorContext(principal.principal_id, principal.credential_id),
)
_require_run_scope(principal, record.scope)
except RunError as error:
raise ValueError(f"{error.code}: {error}") from error
return json.dumps(record.model_dump(mode="json"), ensure_ascii=False)
if context_provider is not None:
available_operations.add("get_project_context")
@server.tool(name="get_project_context", structured_output=False)
def get_project_context(project_id: str, ctx: Context) -> str:
"""Read the authorized project's research lens without exposing its policy."""
principal = _principal(ctx, identity_provider)
if not principal.can_read_project(project_id):
raise PermissionError("Project context is unavailable")
context = context_provider()
project = context.projects.get(project_id)
if project is None:
raise ValueError("Project context is unavailable")
response = ProjectContextResponse(
lens=ProjectLens(
project_id=project.project_id,
title=project.title,
status=project.status,
north_star=project.north_star,
key_questions=project.key_questions,
watch_list=project.watch_list,
taxonomy_extensions=project.taxonomy_extensions,
),
)
encoded = json.dumps(response.model_dump(mode="json"), ensure_ascii=False)
if len(encoded.encode("utf-8")) > MAX_PROJECT_CONTEXT_BYTES:
raise ValueError("Project context exceeds the supported response limit")
return encoded
return server
def _require_project_submission(principal: Principal, project_id: str) -> None:
if not principal.can_submit_project(project_id):
raise PermissionError("Agent credential cannot submit to this project")
def _redacted_source_url(url: str) -> str:
"""Keep a credential-free HTTP locator without query tokens or fragments."""
if len(url) > 4_000 or any(ord(char) < 0x20 or ord(char) == 0x7F for char in url):
return ""
try:
parsed = urlsplit(url)
hostname = parsed.hostname
port = parsed.port
except ValueError:
return ""
scheme = parsed.scheme.lower()
if (
scheme not in {"http", "https"}
or not hostname
or parsed.username is not None
or parsed.password is not None
or port not in {None, 80 if scheme == "http" else 443}
):
return ""
host = hostname.lower()
if ":" in host:
host = f"[{host}]"
netloc = host
return urlunsplit((scheme, netloc, parsed.path or "/", "", ""))
def _url_source_candidate(
safe_url: str,
*,
project_id: str,
title: str | None,
source_type: Literal[
"paper", "review", "press", "vendor", "internal", "talk", "export", "other"
],
reliability_grade: ReliabilityGrade,
license: str,
authors: list[str] | None,
publisher: str | None,
archive_status: Literal["archived", "capture-failed"],
final_url: str | None = None,
artifact_sha256: str | None = None,
media_type: str | None = None,
byte_length: int = 0,
) -> SourceRevisionRecord:
captured_at = datetime.now(UTC)
source_id = f"url:{hashlib.sha256(safe_url.encode('utf-8')).hexdigest()[:32]}"
if archive_status == "archived":
if artifact_sha256 is None:
raise ValueError("Archived URL candidate requires an artifact digest")
capture_stamp = captured_at.strftime("%Y%m%dT%H%M%S%fZ")
revision_id = f"{source_id}:r:{artifact_sha256}:{capture_stamp}"
else:
attempt = captured_at.strftime("%Y%m%dT%H%M%S%fZ")
revision_id = f"{source_id}:failed:{attempt}"
host = urlsplit(safe_url).hostname
return SourceRevisionRecord(
source_id=source_id,
revision_id=revision_id,
artifact_sha256=artifact_sha256,
media_type=media_type or "application/octet-stream",
byte_length=byte_length,
captured_at=captured_at,
capture_method="url-fetch",
original_url=safe_url,
final_url=final_url,
title=(title or host or "Web source")[:500],
authors=tuple(authors or ()),
publisher=publisher,
license=license,
source_type=source_type,
reliability_grade=reliability_grade,
ingested_for=(project_id,),
archive_status=archive_status,
)
def _pasted_text_source_candidate(
source_key: str,
*,
project_id: str,
captured_at: datetime,
title: str | None,
original_url: str | None,
artifact_sha256: str,
media_type: str,
byte_length: int,
source_type: Literal[
"paper", "review", "press", "vendor", "internal", "talk", "export", "other"
],
reliability_grade: ReliabilityGrade,
license: str,
authors: list[str] | None,
publisher: str | None,
) -> SourceRevisionRecord:
identity_digest = hashlib.sha256(source_key.encode("utf-8")).hexdigest()[:30]
source_id = f"pasted:{identity_digest}"
captured_stamp = captured_at.strftime("%Y%m%dT%H%M%S%fZ")
revision_id = f"{source_id}:r:{artifact_sha256}:{captured_stamp}"
return SourceRevisionRecord(
source_id=source_id,
revision_id=revision_id,
artifact_sha256=artifact_sha256,
media_type=media_type,
byte_length=byte_length,
captured_at=captured_at,
capture_method="pasted-text",
original_url=original_url,
title=(title or source_key)[:500],
authors=tuple(authors or ()),
publisher=publisher,
license=license,
source_type=source_type,
reliability_grade=reliability_grade,
ingested_for=(project_id,),
archive_status="archived",
)
def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON key")
result[key] = value
return result
def _parse_proposal(proposal_json: str) -> ProposalRevision:
if len(proposal_json.encode("utf-8")) > 1_000_000:
raise ValueError("Proposal exceeds the supported request size")
try:
parsed = json.loads(proposal_json, object_pairs_hook=_reject_duplicate_keys)
proposal = ProposalRevision.model_validate_json(proposal_json, strict=True)
except (ValueError, ValidationError) as error:
raise ValueError("Proposal does not match the GEWU v1 contract") from error
if not isinstance(parsed, dict):
raise ValueError("Proposal must be a JSON object")
return proposal
def _parse_run_request[RequestT: (RunCreateRequest, RunCheckpointRequest)](
request_json: str, request_type: type[RequestT]
) -> RequestT:
if len(request_json.encode("utf-8")) > 1_000_000:
raise ValueError("Run request exceeds the supported size")
try:
payload = json.loads(request_json, object_pairs_hook=_reject_duplicate_keys)
request = request_type.model_validate_json(request_json, strict=True)
except (ValueError, ValidationError) as error:
raise ValueError("Run request does not match the GEWU v1 contract") from error
if not isinstance(payload, dict):
raise ValueError("Run request must be a JSON object")
return request
def _require_run_scope(principal: Principal, scope: Scope) -> None:
if scope.all_projects:
raise PermissionError("Durable runs require explicit project scope")
affected = set(scope.projects)
if scope.include_shared:
affected.add("shared")
if not affected or any(not principal.can_submit_project(project_id) for project_id in affected):
raise PermissionError("Agent credential lacks a grant for every run project")
def _proposal_evidence(proposal: ProposalRevision) -> Iterator[EvidenceReference]:
for operation in proposal.operations:
if operation.node is not None:
for claim in operation.node.evidence_claims:
yield from claim.evidence
if operation.edge is not None:
yield from operation.edge.evidence
def _proposal_readable_projects(
principal: Principal, proposal: ProposalRevision, context: ValidationContext
) -> frozenset[str]:
"""Limit duplicate review to caller-readable projects explicitly in proposal scope."""
requested = (
set(context.projects) if proposal.scope.all_projects else set(proposal.scope.projects)
)
if proposal.scope.include_shared:
requested.add("shared")
return frozenset(
project_id for project_id in requested if principal.can_read_project(project_id)
)
def _authorize_proposal(
principal: Principal, proposal: ProposalRevision, context: ValidationContext
) -> None:
if "agent" not in principal.roles or proposal.proposer_id != principal.principal_id:
raise PermissionError("An attributed agent credential must submit this proposal")
proposed_nodes = {
operation.node.node_id: operation.node
for operation in proposal.operations
if operation.node is not None
}
known_nodes = {**context.existing_nodes, **proposed_nodes}
affected: set[str] = set()
for operation in proposal.operations:
if operation.source is not None:
affected.update(("shared", *operation.source.ingested_for))
if operation.node is not None:
affected.add(operation.node.project_id)
if operation.edge is not None:
for endpoint in (operation.edge.source, operation.edge.target):
if endpoint.kind == "project":
affected.add(endpoint.id)
elif endpoint.kind == "source":
affected.add("shared")
elif endpoint.id in known_nodes:
affected.add(known_nodes[endpoint.id].project_id)
if operation.target_id is not None and operation.target_id in known_nodes:
affected.add(known_nodes[operation.target_id].project_id)
if not affected or any(not principal.can_submit_project(project_id) for project_id in affected):
raise PermissionError("Agent credential lacks a grant for every affected project")