katalyst-plugin-context 0.1.1a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ # pyright: reportUnusedFunction=false
2
+ # pyright: reportMissingTypeArgument=false, reportUnknownParameterType=false
3
+ # pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
4
+ # pyright: reportArgumentType=false
5
+ # The MCP SDK's Context is generic over types FastMCP never fills in, so every tool
6
+ # signature is partially unknown by construction (as the harness plugin's tools).
7
+ """taxonomy_context_map, taxonomy_context — what an agent working on a node is given."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, cast
12
+
13
+ from katalyst_taxonomy_mcp.response import err, guard_backend_errors, ok
14
+ from mcp.server.fastmcp import Context, FastMCP
15
+
16
+ from katalyst_plugin_context.composition import service_for
17
+ from katalyst_plugin_context.domain.context import ContextError, ContextRequest
18
+ from katalyst_plugin_context.service import ContextService
19
+
20
+
21
+ def _service(ctx: Context, backend: str | None) -> ContextService:
22
+ from katalyst_taxonomy_mcp.server import AppContext
23
+
24
+ app = cast("AppContext", ctx.request_context.lifespan_context)
25
+ return service_for(app.get_registry(), app.read_sources(backend), profile=app.backends.profile)
26
+
27
+
28
+ def _refusal(exc: ContextError) -> dict[str, Any]:
29
+ return err(exc.code, exc.message, exc.details)
30
+
31
+
32
+ def register_context_tools(mcp: FastMCP) -> None:
33
+ """Register the context tools on the MCP server."""
34
+
35
+ @mcp.tool()
36
+ @guard_backend_errors
37
+ def taxonomy_context_map(
38
+ node: str,
39
+ format: str = "json",
40
+ backend: str | None = None,
41
+ ctx: Context = None, # pyright: ignore[reportAssignmentType]
42
+ ) -> dict[str, Any]:
43
+ """Start here before working on a node: what the taxonomy holds around it.
44
+
45
+ Lists every relationship type around NODE in both directions, with the kinds and
46
+ counts at the other end, and what NODE inherits from its ancestors (its parent
47
+ chain), each marked with where it comes from. Also lists the named views its kind
48
+ declares, and returns ('always') the views it always includes, such as the decisions
49
+ and standards that govern it. 'options' says what you may ask for next: the steps
50
+ you may follow, the maximum depth, detail levels and budget. Then call
51
+ taxonomy_context with only what your task needs.
52
+
53
+ Args:
54
+ node: Qualified name, 'backend:fqtn' (or the FQTN when one backend holds it).
55
+ format: 'json', or 'markdown' for ready-to-read text in 'markdown'.
56
+ backend: Read only this backend. Default: every backend the profile reads.
57
+ """
58
+ try:
59
+ service = _service(ctx, backend)
60
+ payload = service.map(node)
61
+ if format == "markdown":
62
+ return ok({"markdown": service.markdown_for_map(payload), "map": payload})
63
+ return ok(payload)
64
+ except ContextError as exc:
65
+ return _refusal(exc)
66
+
67
+ @mcp.tool()
68
+ @guard_backend_errors
69
+ def taxonomy_context(
70
+ node: str,
71
+ paths: list[str] | None = None,
72
+ views: list[str] | None = None,
73
+ depth: int | None = None,
74
+ detail: str | None = None,
75
+ budget: int | None = None,
76
+ inherit: bool | None = None,
77
+ format: str = "json",
78
+ backend: str | None = None,
79
+ ctx: Context = None, # pyright: ignore[reportAssignmentType]
80
+ ) -> dict[str, Any]:
81
+ """The nodes around NODE that your task needs, with why each one is relevant.
82
+
83
+ With no paths or views, returns the views NODE's kind always includes, or the whole
84
+ neighbourhood at depth 1 if it declares none. Otherwise returns exactly what you ask
85
+ for:
86
+ paths steps 'REL[:in|out[:kind,...]]' joined by '/' into a walk, e.g.
87
+ 'SOME_REL:out' or 'A_REL:in/B_REL:in'. Take the steps from
88
+ taxonomy_context_map's options.steps.
89
+ views names from taxonomy_context_map's views.
90
+ depth how many times to follow a single step (default 1).
91
+ detail 'refs' (names and why), 'summary' (plus a short description and key
92
+ fields) or 'full' (every field and the document body).
93
+ budget approximate tokens. Lowest-priority items are shortened first, then cut,
94
+ and 'truncated' says what was cut and how to get it.
95
+ inherit also follow paths from NODE's ancestors (default: per policy).
96
+ Policy set by the schema, the node and the profile can only narrow a request. A
97
+ narrowed request is still answered, and 'policy' says what was narrowed. Each item
98
+ says why it is here (the path, and which ancestor it was inherited from) and, when
99
+ the schema says so, whether it is blocking.
100
+
101
+ Args:
102
+ node: Qualified name, 'backend:fqtn' (or the FQTN when one backend holds it).
103
+ paths: Paths to walk.
104
+ views: Views to return.
105
+ depth: Repetitions of a single-step path.
106
+ detail: refs, summary or full.
107
+ budget: Approximate tokens for the items.
108
+ inherit: Also walk from the node's ancestors.
109
+ format: 'json', or 'markdown' for ready-to-read text in 'markdown'.
110
+ backend: Read only this backend. Default: every backend the profile reads.
111
+ """
112
+ request = ContextRequest(
113
+ paths=tuple(paths) if paths is not None else None,
114
+ views=tuple(views) if views is not None else None,
115
+ depth=depth,
116
+ detail=detail,
117
+ budget=budget,
118
+ inherit=inherit,
119
+ format=format,
120
+ )
121
+ try:
122
+ service = _service(ctx, backend)
123
+ payload = service.query(node, request)
124
+ if format == "markdown":
125
+ return ok(
126
+ {"markdown": service.markdown_for_result(node, payload), "result": payload}
127
+ )
128
+ return ok(payload)
129
+ except ContextError as exc:
130
+ return _refusal(exc)
131
+
132
+ _ = (taxonomy_context_map, taxonomy_context)
@@ -0,0 +1,64 @@
1
+ """Context plugin: what an agent working on a node gets from the taxonomy.
2
+
3
+ Contributes the MCP tools (``taxonomy_context_map``, ``taxonomy_context``), the REST routes
4
+ (``/ext/context``) and the ``context-policy`` lint rule. ``kata tax context`` arrives over
5
+ the ``katalyst_taxonomy.cli`` entry point. It ships no kinds: bundles declare what their
6
+ kinds' context is in ``plugin_settings["context"]``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from importlib.metadata import PackageNotFoundError, version
12
+ from typing import Any
13
+
14
+ from katalyst_taxonomy.plugins.protocol import PluginContext, PluginManifest
15
+
16
+ from katalyst_plugin_context.schema import CONFIG_SCHEMA, NODE_SETTINGS_SCHEMA, SETTINGS_SCHEMA
17
+ from katalyst_plugin_context.service import NAMESPACE
18
+
19
+
20
+ def _version() -> str:
21
+ try:
22
+ return version("katalyst-plugin-context")
23
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
24
+ return ""
25
+
26
+
27
+ class ContextPlugin:
28
+ """The right context for an agent working on a node, over MCP, REST and the CLI."""
29
+
30
+ @property
31
+ def name(self) -> str:
32
+ return NAMESPACE
33
+
34
+ @property
35
+ def manifest(self) -> PluginManifest:
36
+ return PluginManifest(
37
+ name=NAMESPACE,
38
+ version=_version(),
39
+ description=(
40
+ "Give an agent working on a node the right context: the relationships "
41
+ "around it, what it inherits, and the views its kind declares, narrowed "
42
+ "by schema, node and profile policy"
43
+ ),
44
+ capabilities=("context", "agents"),
45
+ config_schema=CONFIG_SCHEMA,
46
+ settings_schema=SETTINGS_SCHEMA,
47
+ node_settings_schema=NODE_SETTINGS_SCHEMA,
48
+ )
49
+
50
+ def register(self, context: PluginContext) -> None:
51
+ from katalyst_plugin_context.lint import ContextPolicyRule
52
+ from katalyst_plugin_context.rest import ContextRestRoutes
53
+
54
+ context.lint_rules.append(ContextPolicyRule(context.schema_registry))
55
+ context.rest_routes.append(ContextRestRoutes())
56
+
57
+ def _register_mcp(mcp: Any) -> None:
58
+ # Imported only when an MCP server calls this, so a host that never serves
59
+ # MCP does not need the MCP SDK just to load plugins.
60
+ from katalyst_plugin_context.mcp_tools import register_context_tools
61
+
62
+ register_context_tools(mcp)
63
+
64
+ context.mcp_tools.append(_register_mcp)
File without changes
@@ -0,0 +1,133 @@
1
+ # pyright: reportUnusedFunction=false
2
+ #
3
+ # The routes are nested functions registered by their decorators and never referenced by
4
+ # name; the REST app mounts the router this returns.
5
+ """``/ext/context`` — the context map, a context query and the effective policy over REST.
6
+
7
+ ============================== ==========================================================
8
+ Route Answers
9
+ ============================== ==========================================================
10
+ ``GET /ext/context/map`` the map around ``node`` (``format=markdown`` adds text)
11
+ ``POST /ext/context/query`` the items a request asks for (``paths``, ``views``, ...)
12
+ ``GET /ext/context/policy`` the effective policy for ``node``: layers, limits, notes
13
+ ============================== ==========================================================
14
+
15
+ Every route only reads. Each needs the API's bearer token, as every route does.
16
+ """
17
+
18
+ # No `from __future__ import annotations`: FastAPI reads the handlers' annotations, and
19
+ # the request model is local to router(), so they must be evaluated where it is in scope.
20
+ from typing import Any
21
+
22
+
23
+ class ContextRestRoutes:
24
+ """Implements :class:`~katalyst_taxonomy.plugins.extensions.RestRoutes`."""
25
+
26
+ @property
27
+ def name(self) -> str:
28
+ return "context"
29
+
30
+ def router(self) -> Any:
31
+ from fastapi import APIRouter, Request
32
+ from fastapi.responses import JSONResponse
33
+ from katalyst_engine.schema.registry import SchemaRegistry
34
+ from katalyst_taxonomy.config.routing import BackendAddressError
35
+ from katalyst_taxonomy.engine.bridge import get_default_registry
36
+ from katalyst_taxonomy_mcp.response import ErrorCode, backend_failure, err, ok
37
+ from pydantic import BaseModel, Field
38
+
39
+ from katalyst_plugin_context.composition import service_for
40
+ from katalyst_plugin_context.domain.context import ContextError, ContextRequest
41
+ from katalyst_plugin_context.service import ContextService
42
+
43
+ router = APIRouter()
44
+ status_of = {"NOT_FOUND": 404, "INVALID_INPUT": 422, "VALIDATION_ERROR": 422}
45
+
46
+ class QueryBody(BaseModel):
47
+ node: str = Field(description="Qualified name, 'backend:fqtn'.")
48
+ paths: list[str] | None = None
49
+ views: list[str] | None = None
50
+ depth: int | None = None
51
+ detail: str | None = None
52
+ budget: int | None = None
53
+ inherit: bool | None = None
54
+ format: str = "json"
55
+ backend: str | None = None
56
+
57
+ def service(request: Request, backend: str | None) -> ContextService | JSONResponse:
58
+ registry = request.app.state.backend_registry
59
+ if registry is None:
60
+ reason = request.app.state.backend_error or "No backend is configured."
61
+ return JSONResponse(
62
+ status_code=409,
63
+ content=err(ErrorCode.NO_BACKEND_CONFIGURED, reason, {"configured": False}),
64
+ )
65
+ schema: SchemaRegistry | None = getattr(request.app.state, "schema_registry", None)
66
+ try:
67
+ sources = registry.read_set(backend)
68
+ except BackendAddressError as exc:
69
+ return JSONResponse(status_code=422, content=backend_failure(exc))
70
+ try:
71
+ return service_for(
72
+ schema or get_default_registry(), sources, profile=registry.profile
73
+ )
74
+ except ContextError as exc:
75
+ return refuse(exc)
76
+
77
+ def refuse(exc: ContextError) -> JSONResponse:
78
+ return JSONResponse(
79
+ status_code=status_of.get(exc.code, 422),
80
+ content=err(exc.code, exc.message, exc.details),
81
+ )
82
+
83
+ @router.get("/map", response_model=None)
84
+ def get_map(
85
+ request: Request, node: str, format: str = "json", backend: str | None = None
86
+ ) -> dict[str, Any] | JSONResponse:
87
+ found = service(request, backend)
88
+ if isinstance(found, JSONResponse):
89
+ return found
90
+ try:
91
+ payload = found.map(node)
92
+ except ContextError as exc:
93
+ return refuse(exc)
94
+ if format == "markdown":
95
+ return ok({"markdown": found.markdown_for_map(payload), "map": payload})
96
+ return ok(payload)
97
+
98
+ @router.post("/query", response_model=None)
99
+ def post_query(request: Request, body: QueryBody) -> dict[str, Any] | JSONResponse:
100
+ found = service(request, body.backend)
101
+ if isinstance(found, JSONResponse):
102
+ return found
103
+ asked = ContextRequest(
104
+ paths=tuple(body.paths) if body.paths is not None else None,
105
+ views=tuple(body.views) if body.views is not None else None,
106
+ depth=body.depth,
107
+ detail=body.detail,
108
+ budget=body.budget,
109
+ inherit=body.inherit,
110
+ format=body.format,
111
+ )
112
+ try:
113
+ payload = found.query(body.node, asked)
114
+ if body.format == "markdown":
115
+ markdown = found.markdown_for_result(body.node, payload)
116
+ return ok({"markdown": markdown, "result": payload})
117
+ except ContextError as exc:
118
+ return refuse(exc)
119
+ return ok(payload)
120
+
121
+ @router.get("/policy", response_model=None)
122
+ def get_policy(
123
+ request: Request, node: str, backend: str | None = None
124
+ ) -> dict[str, Any] | JSONResponse:
125
+ found = service(request, backend)
126
+ if isinstance(found, JSONResponse):
127
+ return found
128
+ try:
129
+ return ok(found.explain(node))
130
+ except ContextError as exc:
131
+ return refuse(exc)
132
+
133
+ return router
@@ -0,0 +1,200 @@
1
+ """The JSON Schemas the context plugin publishes for its three kinds of settings.
2
+
3
+ - :data:`CONFIG_SCHEMA`: its ``spec.plugins[].config`` in config.yaml (``PluginManifest.config_schema``);
4
+ - :data:`SETTINGS_SCHEMA`: its ``plugin_settings["context"]`` section on a wing or kind;
5
+ - :data:`NODE_SETTINGS_SCHEMA`: a node's ``spec.extensions.context``.
6
+
7
+ ``kata tax lint`` checks bundle and node sections against the last two. A config save checks
8
+ the first. The plugin checks all three again on every call, and a section that fails is
9
+ refused rather than ignored, because ignoring a narrowing would widen what an agent sees.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Mapping
15
+ from typing import Any, cast
16
+
17
+ from katalyst_plugin_context.domain.context import ContextError
18
+ from katalyst_plugin_context.domain.paths import STEP_RE
19
+
20
+ __all__ = [
21
+ "CONFIG_SCHEMA",
22
+ "NODE_SETTINGS_SCHEMA",
23
+ "SETTINGS_SCHEMA",
24
+ "check",
25
+ ]
26
+
27
+ _DRAFT = "https://json-schema.org/draft/2020-12/schema"
28
+ _DETAIL = {"enum": ["refs", "summary", "full"]}
29
+ _STEP = {"type": "string", "pattern": f"^{STEP_RE}$"}
30
+ _PATH = {"type": "string", "pattern": f"^{STEP_RE}(/{STEP_RE})*$"}
31
+ _NAME = "^[a-z][a-z0-9-]*$"
32
+
33
+ _POLICY: dict[str, Any] = {
34
+ "type": "object",
35
+ "title": "Policy",
36
+ "description": "Limits that can only narrow what an agent is given.",
37
+ "additionalProperties": False,
38
+ "properties": {
39
+ "paths": {
40
+ "type": "object",
41
+ "additionalProperties": False,
42
+ "properties": {
43
+ "allow": {
44
+ "type": "array",
45
+ "items": _STEP,
46
+ "description": "Only steps these patterns match are visible.",
47
+ },
48
+ "deny": {
49
+ "type": "array",
50
+ "items": _STEP,
51
+ "description": "Steps these patterns match are hidden.",
52
+ },
53
+ },
54
+ },
55
+ "max_depth": {"type": "integer", "minimum": 0, "description": "Most hops from the node."},
56
+ "detail": {
57
+ "type": "array",
58
+ "items": _DETAIL,
59
+ "description": "Detail levels allowed (refs is always allowed).",
60
+ },
61
+ "budget": {
62
+ "type": "object",
63
+ "additionalProperties": False,
64
+ "properties": {"max": {"type": "integer", "minimum": 1}},
65
+ },
66
+ "inheritance": {"type": "boolean", "description": "Whether anything may be inherited."},
67
+ "always_include": {
68
+ "type": "array",
69
+ "items": {"type": "string", "pattern": _NAME},
70
+ "description": "Views returned without being asked; only declared views count.",
71
+ },
72
+ },
73
+ }
74
+
75
+ _DEFAULTS: dict[str, Any] = {
76
+ "type": "object",
77
+ "title": "Defaults",
78
+ "description": "Used when an agent does not say; clamped into the policy.",
79
+ "additionalProperties": False,
80
+ "properties": {
81
+ "depth": {"type": "integer", "minimum": 0},
82
+ "detail": _DETAIL,
83
+ "budget": {"type": "integer", "minimum": 1},
84
+ "inherit": {"type": "boolean"},
85
+ },
86
+ }
87
+
88
+ _VIEW: dict[str, Any] = {
89
+ "type": "object",
90
+ "additionalProperties": False,
91
+ "required": ["paths"],
92
+ "properties": {
93
+ "title": {"type": "string"},
94
+ "description": {"type": "string"},
95
+ "paths": {"type": "array", "items": _PATH, "minItems": 1},
96
+ "inherit": {"type": "boolean"},
97
+ "detail": _DETAIL,
98
+ "priority": {"type": "integer"},
99
+ "order": {"enum": ["blocking-first"]},
100
+ },
101
+ }
102
+
103
+ _RULE: dict[str, Any] = {
104
+ "type": "object",
105
+ "additionalProperties": False,
106
+ "properties": {
107
+ "match": {
108
+ "type": "object",
109
+ "additionalProperties": False,
110
+ "properties": {
111
+ "backend": {"type": "array", "items": {"type": "string"}},
112
+ "kind": {"type": "array", "items": {"type": "string"}},
113
+ "wing": {"type": "array", "items": {"type": "string"}},
114
+ "fqtn": {"type": "array", "items": {"type": "string"}},
115
+ "name": {"type": "array", "items": {"type": "string"}},
116
+ "labels": {"type": "object", "additionalProperties": {"type": "string"}},
117
+ },
118
+ },
119
+ "policy": _POLICY,
120
+ "defaults": _DEFAULTS,
121
+ },
122
+ }
123
+
124
+ CONFIG_SCHEMA: dict[str, Any] = {
125
+ "$schema": _DRAFT,
126
+ "type": "object",
127
+ "title": "Context",
128
+ "additionalProperties": False,
129
+ "properties": {
130
+ "policy": _POLICY,
131
+ "defaults": _DEFAULTS,
132
+ "profiles": {
133
+ "type": "object",
134
+ "description": "Stricter policy for one profile, by profile name.",
135
+ "additionalProperties": {
136
+ "type": "object",
137
+ "additionalProperties": False,
138
+ "properties": {
139
+ "policy": _POLICY,
140
+ "defaults": _DEFAULTS,
141
+ "rules": {"type": "array", "items": _RULE},
142
+ },
143
+ },
144
+ },
145
+ },
146
+ }
147
+
148
+ SETTINGS_SCHEMA: dict[str, Any] = {
149
+ "$schema": _DRAFT,
150
+ "type": "object",
151
+ "additionalProperties": False,
152
+ "properties": {
153
+ "title_field": {"type": "string", "description": "Spec field holding an item's title."},
154
+ "summary_fields": {"type": "array", "items": {"type": "string"}},
155
+ "priority": {"type": "integer"},
156
+ "views": {
157
+ "type": "object",
158
+ "propertyNames": {"pattern": _NAME},
159
+ "additionalProperties": _VIEW,
160
+ },
161
+ "policy": _POLICY,
162
+ "defaults": _DEFAULTS,
163
+ },
164
+ }
165
+
166
+ NODE_SETTINGS_SCHEMA: dict[str, Any] = {
167
+ "$schema": _DRAFT,
168
+ "type": "object",
169
+ "additionalProperties": False,
170
+ "properties": {"policy": _POLICY, "defaults": _DEFAULTS},
171
+ }
172
+
173
+
174
+ def _location(parts: list[object]) -> str:
175
+ return "".join(f"[{p}]" if isinstance(p, int) else f".{p}" for p in parts)
176
+
177
+
178
+ def check(schema: Mapping[str, Any], instance: object, *, where: str) -> None:
179
+ """Refuse *instance* when *schema* does not accept it.
180
+
181
+ Raises:
182
+ ContextError: ``VALIDATION_ERROR``, naming *where* and the first problem's key.
183
+ """
184
+ from jsonschema import validators
185
+
186
+ validator_class = cast("Any", validators.validator_for(dict(schema)))
187
+ errors = sorted(
188
+ validator_class(dict(schema)).iter_errors(instance),
189
+ key=lambda e: [str(p) for p in e.absolute_path],
190
+ )
191
+ if errors:
192
+ first = errors[0]
193
+ raise ContextError(
194
+ "VALIDATION_ERROR",
195
+ f"{where}{_location(list(first.absolute_path))}: {first.message}",
196
+ {
197
+ "where": where,
198
+ "problems": [f"{_location(list(e.absolute_path))}: {e.message}" for e in errors],
199
+ },
200
+ )