Files
ITWorx_MCP_Hub-Public/connectors/knowledge/server.py
T

386 lines
13 KiB
Python

"""Project Knowledge connector surface: read-only, scoped, source-backed MCP contracts."""
from __future__ import annotations
from collections.abc import Callable, Iterable
from datetime import UTC, datetime
from typing import Any
from packages.connector_kit import (
ConnectorContext,
InMemoryKnowledgeIndex,
PageRequest,
ResourcePublication,
SourceType,
ToolPublication,
decode_cursor,
encode_cursor,
)
from packages.connector_kit.knowledge import DocumentNotFound
from packages.hub_policy import Governance, governed_tool
class KnowledgeConnector:
"""Expose only registered project documents; no physical path is an input."""
connector_type = "knowledge"
version = "1.0.0"
def __init__(self, index: InMemoryKnowledgeIndex, governance: Governance | None = None) -> None:
self.index = index
self.governance = governance
def _envelope(
self,
context: ConnectorContext,
data: Any,
*,
truncated: bool = False,
next_cursor: str | None = None,
) -> dict[str, Any]:
return {
"data": data,
"meta": {
"project": context.project_slug,
"environment": context.environment,
"source": "knowledge",
"truncated": truncated,
"nextCursor": next_cursor,
"generatedAt": datetime.now(UTC).isoformat(),
},
"warnings": [],
}
@staticmethod
def _hit_payload(item: Any) -> dict[str, Any]:
return {
"uri": item.uri,
"title": item.title,
"path": item.path,
"snippet": item.snippet,
"sourceType": item.source_type,
"sourceRevision": item.source_revision,
"repositoryRef": item.repository_ref,
"repositoryCommit": item.repository_commit,
"contentHash": item.content_hash,
"modifiedAt": item.modified_at.isoformat(),
"score": item.score,
"truncated": item.truncated,
}
@governed_tool("knowledge.document.search")
def document_search(
self,
context: ConnectorContext,
query: str,
*,
source_types: Iterable[SourceType] = (),
page: PageRequest | None = None,
) -> dict[str, Any]:
request = page or PageRequest()
result = self.index.search(
context.project_id,
query,
source_types=source_types,
limit=min(request.limit, 20),
cursor=request.cursor,
)
data = {
"items": [self._hit_payload(item) for item in result.items],
"nextCursor": result.next_cursor,
"truncated": result.truncated,
}
return self._envelope(
context, data, truncated=result.truncated, next_cursor=result.next_cursor
)
@governed_tool("knowledge.document.get")
def document_get(
self, context: ConnectorContext, uri: str, *, max_bytes: int = 200_000
) -> dict[str, Any]:
if not 1 <= max_bytes <= 262_144:
raise ValueError("max_bytes is outside the connector output budget")
document = self.index.get_document(context.project_id, uri)
encoded = document.content.encode("utf-8")
truncated = len(encoded) > max_bytes
content = (
encoded[:max_bytes].decode("utf-8", errors="ignore") if truncated else document.content
)
data = {
"uri": document.uri,
"title": document.title,
"mimeType": document.mime_type,
"content": content,
"path": document.external_ref,
"sourceType": document.source_type,
"sourceRevision": document.source_revision,
"repositoryRef": document.repository_ref,
"repositoryCommit": document.repository_commit,
"contentHash": document.content_hash,
"modifiedAt": document.modified_at.isoformat(),
"truncated": truncated,
}
return self._envelope(context, data, truncated=truncated)
@governed_tool("knowledge.source.list")
def source_list(
self,
context: ConnectorContext,
*,
source_type: SourceType | None = None,
page: PageRequest | None = None,
) -> dict[str, Any]:
request = page or PageRequest(limit=25)
sources = [
source
for source in self.index.list_sources(context.project_id)
if source_type is None or source.source_type == source_type
]
cursor = decode_cursor(
request.cursor, str(context.project_id), "sources", str(source_type or "")
)
items = sources[cursor.offset : cursor.offset + min(request.limit, 100)]
offset = cursor.offset + len(items)
next_cursor = (
encode_cursor(offset, str(context.project_id), "sources", str(source_type or ""))
if offset < len(sources)
else None
)
data = {
"items": [
{
"sourceRef": item.source_ref,
"sourceType": item.source_type,
"uri": item.uri,
"status": item.status,
"sourceRevision": item.source_revision,
"lastIndexedAt": item.last_indexed_at.isoformat()
if item.last_indexed_at
else None,
"documentCount": item.document_count,
}
for item in items
],
"nextCursor": next_cursor,
"truncated": next_cursor is not None,
}
return self._envelope(
context, data, truncated=next_cursor is not None, next_cursor=next_cursor
)
@governed_tool("knowledge.index.status")
def index_status(self, context: ConnectorContext) -> dict[str, Any]:
status = self.index.status(context.project_id)
data = {
"status": status.status,
"sourceCount": status.source_count,
"documentCount": status.document_count,
"lastCompletedAt": status.last_completed_at.isoformat()
if status.last_completed_at
else None,
"lastError": status.last_error,
"generatedAt": datetime.now(UTC),
}
return self._envelope(context, data)
@governed_tool("knowledge.resource.get")
def resource_get(self, context: ConnectorContext, uri: str) -> dict[str, Any]:
if not uri.startswith("itworx-docs://"):
raise DocumentNotFound("resource URI is not registered")
return self.document_get(context, uri)
@governed_tool("knowledge.adr.list")
def adr_list(
self, context: ConnectorContext, *, page: PageRequest | None = None
) -> dict[str, Any]:
request = page or PageRequest(limit=25)
documents = self.index.documents_for_source(context.project_id, SourceType.ADR)
cursor = decode_cursor(request.cursor, str(context.project_id), "adr")
items = documents[cursor.offset : cursor.offset + min(request.limit, 100)]
offset = cursor.offset + len(items)
next_cursor = (
encode_cursor(offset, str(context.project_id), "adr")
if offset < len(documents)
else None
)
data = {
"items": [
{
"uri": doc.uri,
"number": doc.external_ref,
"title": doc.title,
"status": "indexed",
"updatedAt": doc.modified_at.isoformat(),
}
for doc in items
],
"nextCursor": next_cursor,
"truncated": next_cursor is not None,
}
return self._envelope(
context, data, truncated=next_cursor is not None, next_cursor=next_cursor
)
@governed_tool("knowledge.project.summary")
def project_summary(self, context: ConnectorContext) -> dict[str, Any]:
status = self.index.status(context.project_id)
sources = [source.uri for source in self.index.list_sources(context.project_id)]
summary = (
f"{status.document_count} indexed documents across "
f"{status.source_count} registered sources."
)
return self._envelope(
context,
{
"summary": summary,
"sources": sources,
"sourceRevision": status.last_completed_at.isoformat()
if status.last_completed_at
else "never",
"generatedAt": datetime.now(UTC),
},
)
def tools(self) -> tuple[ToolPublication, ...]:
return KNOWLEDGE_TOOLS
def resources(self) -> tuple[ResourcePublication, ...]:
return KNOWLEDGE_RESOURCES
def invoke(self, tool: str, context: ConnectorContext, **arguments: Any) -> dict[str, Any]:
methods: dict[str, Callable[..., dict[str, Any]]] = {
"knowledge.document.search": self.document_search,
"knowledge.document.get": self.document_get,
"knowledge.source.list": self.source_list,
"knowledge.index.status": self.index_status,
"knowledge.adr.list": self.adr_list,
"knowledge.project.summary": self.project_summary,
"knowledge.resource.get": self.resource_get,
}
method = methods.get(tool)
if method is None:
raise ValueError("unknown knowledge tool")
return method(context, **arguments)
def health(self) -> dict[str, Any]:
return {"status": "healthy", "component": "knowledge-connector"}
def readiness(self) -> dict[str, Any]:
return {"status": "ready", "component": "knowledge-connector"}
_COMMON_EMPTY_INPUT = {
"type": "object",
"additionalProperties": False,
"properties": {},
"required": [],
}
_COMMON_PAGE = {
"type": "object",
"additionalProperties": False,
"properties": {
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
"cursor": {"type": ["string", "null"], "maxLength": 500},
},
"required": [],
}
_COMMON_OUTPUT = {
"type": "object",
"additionalProperties": False,
"properties": {
"data": {"type": "object"},
"meta": {"type": "object"},
"warnings": {"type": "array"},
},
"required": ["data", "meta", "warnings"],
}
KNOWLEDGE_TOOLS = (
ToolPublication(
canonical_name="knowledge.document.search",
version="1.0.0",
connector_type="knowledge",
risk_class="R1",
input_schema={
"type": "object",
"additionalProperties": False,
"properties": {
"query": {"type": "string", "minLength": 1, "maxLength": 1000},
"sourceTypes": {"type": "array", "maxItems": 8},
"limit": {"type": "integer", "minimum": 1, "maximum": 20},
"cursor": {"type": ["string", "null"], "maxLength": 500},
},
"required": ["query"],
},
output_schema=_COMMON_OUTPUT,
required_scopes=("project:{project}:read",),
),
ToolPublication(
canonical_name="knowledge.document.get",
version="1.0.0",
connector_type="knowledge",
risk_class="R1",
input_schema={
"type": "object",
"additionalProperties": False,
"properties": {
"uri": {
"type": "string",
"pattern": "^itworx-docs://",
"maxLength": 2048,
}
},
"required": ["uri"],
},
output_schema=_COMMON_OUTPUT,
required_scopes=("project:{project}:read",),
),
ToolPublication(
canonical_name="knowledge.source.list",
version="1.0.0",
connector_type="knowledge",
risk_class="R1",
input_schema=_COMMON_PAGE,
output_schema=_COMMON_OUTPUT,
required_scopes=("project:{project}:read",),
),
ToolPublication(
canonical_name="knowledge.index.status",
version="1.0.0",
connector_type="knowledge",
risk_class="R1",
input_schema=_COMMON_EMPTY_INPUT,
output_schema=_COMMON_OUTPUT,
required_scopes=("project:{project}:read",),
),
ToolPublication(
canonical_name="knowledge.adr.list",
version="1.0.0",
connector_type="knowledge",
risk_class="R1",
input_schema=_COMMON_PAGE,
output_schema=_COMMON_OUTPUT,
required_scopes=("project:{project}:read",),
),
ToolPublication(
canonical_name="knowledge.project.summary",
version="1.0.0",
connector_type="knowledge",
risk_class="R1",
input_schema=_COMMON_EMPTY_INPUT,
output_schema=_COMMON_OUTPUT,
required_scopes=("project:{project}:read",),
),
)
KNOWLEDGE_RESOURCES = (
ResourcePublication(
uri_template="itworx-docs://{project}/{source}/{documentId}",
connector_type="knowledge",
risk_class="R1",
mime_types=("text/markdown", "text/plain", "application/json"),
max_bytes=262_144,
required_scopes=("project:{project}:read",),
),
)