1254 lines
54 KiB
Python
1254 lines
54 KiB
Python
"""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")
|