harnessapi 0.1.0__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.
harnessapi/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ from .app import HarnessAPI
2
+ from .decorators import skill
3
+ from .exceptions import (
4
+ EditNotAllowedError,
5
+ SkillAPIError,
6
+ SkillConflictError,
7
+ SkillHandlerError,
8
+ SkillNotFoundError,
9
+ SkillValidationError,
10
+ )
11
+ from .models import SkillInput, SkillOutput
12
+ from .skill import Skill, SkillMeta
13
+
14
+ __all__ = [
15
+ "HarnessAPI",
16
+ "Skill",
17
+ "SkillMeta",
18
+ "SkillInput",
19
+ "SkillOutput",
20
+ "skill",
21
+ "SkillAPIError",
22
+ "SkillNotFoundError",
23
+ "SkillValidationError",
24
+ "SkillConflictError",
25
+ "SkillHandlerError",
26
+ "EditNotAllowedError",
27
+ ]
harnessapi/app.py ADDED
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from fastapi import FastAPI
8
+
9
+ from .decorators import get_registered_skills
10
+ from .discovery import SkillsDirectoryProvider
11
+ from .exceptions import SkillConflictError
12
+ from .mcp import build_mcp_server, register_skill_as_mcp_tool
13
+ from .routing import EditRoute, SkillRoute
14
+ from .skill import Skill
15
+
16
+
17
+ class HarnessAPI(FastAPI):
18
+ """FastAPI subclass that auto-discovers skills and exposes them as HTTP + MCP."""
19
+
20
+ def __init__(
21
+ self,
22
+ *,
23
+ skills_dir: str | Path | None = None,
24
+ mcp_path: str = "/mcp",
25
+ mcp_server_name: str = "HarnessAPI",
26
+ enable_edit_endpoints: bool = False,
27
+ **fastapi_kwargs: Any,
28
+ ) -> None:
29
+ self._mcp = build_mcp_server(mcp_server_name)
30
+ self._skills: dict[str, Skill] = {}
31
+ self._mcp_path = mcp_path
32
+ self._enable_edit = enable_edit_endpoints
33
+
34
+ mcp_app = self._mcp.http_app(path="/")
35
+ user_lifespan = fastapi_kwargs.pop("lifespan", None)
36
+
37
+ @asynccontextmanager
38
+ async def merged_lifespan(app):
39
+ async with mcp_app.lifespan(mcp_app):
40
+ if user_lifespan is not None:
41
+ async with user_lifespan(app):
42
+ yield
43
+ else:
44
+ yield
45
+
46
+ super().__init__(lifespan=merged_lifespan, **fastapi_kwargs)
47
+
48
+ # Folder-based discovery
49
+ if skills_dir is not None:
50
+ for skill in SkillsDirectoryProvider(skills_dir).discover():
51
+ self._register_skill(skill)
52
+
53
+ # Decorator-based skills
54
+ for skill in get_registered_skills():
55
+ if skill.meta.name not in self._skills:
56
+ self._register_skill(skill)
57
+
58
+ # Mount FastMCP ASGI app
59
+ self.mount(mcp_path, mcp_app)
60
+
61
+ # ------------------------------------------------------------------
62
+ def _register_skill(self, skill: Skill) -> None:
63
+ name = skill.meta.name
64
+ if name in self._skills:
65
+ raise SkillConflictError(f"Skill '{name}' is already registered")
66
+ self._skills[name] = skill
67
+ self.router.routes.append(SkillRoute(skill))
68
+ if self._enable_edit:
69
+ self.router.routes.append(EditRoute(skill))
70
+ register_skill_as_mcp_tool(self._mcp, skill)
71
+
72
+ def add_skill(self, skill: Skill) -> None:
73
+ """Programmatically register a skill after startup."""
74
+ self._register_skill(skill)
75
+
76
+ @property
77
+ def skills(self) -> dict[str, Skill]:
78
+ return dict(self._skills)
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ from typing import Callable
5
+
6
+ from .models import SkillInput, SkillOutput
7
+ from .skill import Skill, SkillMeta
8
+
9
+ _registry: dict[str, Skill] = {}
10
+
11
+
12
+ def skill(
13
+ name: str | None = None,
14
+ *,
15
+ description: str | None = None,
16
+ is_mcp: bool = True,
17
+ tags: list[str] | None = None,
18
+ timeout_secs: float | None = 30.0,
19
+ input_model: type[SkillInput] | None = None,
20
+ output_model: type[SkillOutput] | None = None,
21
+ ) -> Callable:
22
+ """Decorator to register a skill without a folder.
23
+
24
+ Usage::
25
+
26
+ @skill(name="greet", input_model=GreetInput, output_model=GreetOutput)
27
+ async def greet(input: GreetInput) -> GreetOutput: ...
28
+ """
29
+ def decorator(fn: Callable) -> Callable:
30
+ skill_name = name or fn.__name__
31
+ in_model = input_model
32
+ out_model = output_model
33
+
34
+ if in_model is None or out_model is None:
35
+ hints = getattr(fn, "__annotations__", {})
36
+ if in_model is None:
37
+ in_model = hints.get("input") or hints.get("return")
38
+ if out_model is None:
39
+ out_model = hints.get("return")
40
+
41
+ if in_model is None or out_model is None:
42
+ raise TypeError(
43
+ f"@skill('{skill_name}'): provide input_model and output_model, "
44
+ "or annotate the function with Input and return types."
45
+ )
46
+
47
+ meta = SkillMeta(
48
+ name=skill_name,
49
+ description=description or fn.__doc__ or "",
50
+ is_mcp=is_mcp,
51
+ tags=tags or [],
52
+ timeout_secs=timeout_secs,
53
+ )
54
+ s = Skill(
55
+ meta=meta,
56
+ input_model=in_model,
57
+ output_model=out_model,
58
+ handler=fn,
59
+ edit_handler=None,
60
+ folder=None,
61
+ )
62
+ _registry[skill_name] = s
63
+
64
+ @functools.wraps(fn)
65
+ async def wrapper(*args, **kwargs):
66
+ return await fn(*args, **kwargs)
67
+
68
+ wrapper.__skill__ = s # type: ignore[attr-defined]
69
+ return wrapper
70
+
71
+ return decorator
72
+
73
+
74
+ def get_registered_skills() -> list[Skill]:
75
+ return list(_registry.values())
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import importlib.util
5
+ import json
6
+ import tomllib
7
+ import types
8
+ from pathlib import Path
9
+ from typing import Iterator
10
+
11
+ from .models import SkillInput, SkillOutput
12
+ from .skill import Skill, SkillMeta
13
+
14
+
15
+ class SkillsDirectoryProvider:
16
+ """Scans a directory tree and yields a Skill for each valid skill folder."""
17
+
18
+ def __init__(self, root: Path | str) -> None:
19
+ self.root = Path(root)
20
+
21
+ def discover(self) -> Iterator[Skill]:
22
+ for folder in sorted(self.root.iterdir()):
23
+ if folder.is_dir() and not folder.name.startswith("_"):
24
+ skill = self._load_skill(folder)
25
+ if skill is not None:
26
+ yield skill
27
+
28
+ # ------------------------------------------------------------------
29
+ def _load_skill(self, folder: Path) -> Skill | None:
30
+ handler_path = folder / "handler.py"
31
+ models_path = folder / "models.py"
32
+ if not handler_path.exists() or not models_path.exists():
33
+ return None
34
+
35
+ meta = self._load_meta(folder)
36
+ pkg = self._make_package(folder)
37
+ models_mod = self._load_module(models_path, f"{pkg.__name__}.models", pkg)
38
+ handler_mod = self._load_module(handler_path, f"{pkg.__name__}.handler", pkg)
39
+
40
+ input_model = getattr(models_mod, "Input", None)
41
+ output_model = getattr(models_mod, "Output", None)
42
+ if input_model is None or output_model is None:
43
+ raise TypeError(
44
+ f"Skill '{folder.name}': models.py must define Input and Output classes"
45
+ )
46
+ if not issubclass(input_model, SkillInput):
47
+ raise TypeError(
48
+ f"Skill '{folder.name}': Input must subclass SkillInput"
49
+ )
50
+ if not issubclass(output_model, SkillOutput):
51
+ raise TypeError(
52
+ f"Skill '{folder.name}': Output must subclass SkillOutput"
53
+ )
54
+
55
+ handle_fn = getattr(handler_mod, "handle", None)
56
+ if handle_fn is None:
57
+ raise TypeError(
58
+ f"Skill '{folder.name}': handler.py must define a 'handle' function"
59
+ )
60
+
61
+ return Skill(
62
+ meta=meta,
63
+ input_model=input_model,
64
+ output_model=output_model,
65
+ handler=handle_fn,
66
+ edit_handler=self._load_edit_handler(folder, pkg),
67
+ folder=folder,
68
+ examples=self._load_examples(folder),
69
+ defaults=self._load_defaults(folder),
70
+ )
71
+
72
+ @staticmethod
73
+ def _make_package(folder: Path) -> types.ModuleType:
74
+ """Create a synthetic package namespace for a skill folder."""
75
+ import sys
76
+ pkg_name = f"_harnessapi_skill_{folder.name}"
77
+ if pkg_name in sys.modules:
78
+ return sys.modules[pkg_name]
79
+ pkg = types.ModuleType(pkg_name)
80
+ pkg.__path__ = [str(folder)] # type: ignore[assignment]
81
+ pkg.__package__ = pkg_name
82
+ pkg.__spec__ = None # type: ignore[assignment]
83
+ sys.modules[pkg_name] = pkg
84
+ return pkg
85
+
86
+ def _load_meta(self, folder: Path) -> SkillMeta:
87
+ toml_path = folder / "skill.toml"
88
+ data: dict = {}
89
+ if toml_path.exists():
90
+ with toml_path.open("rb") as f:
91
+ raw = tomllib.load(f)
92
+ data = raw.get("skill", {})
93
+ handler_path = folder / "handler.py"
94
+ description = data.get("description") or _extract_docstring(handler_path) or ""
95
+ return SkillMeta(
96
+ name=data.get("name", folder.name),
97
+ description=description,
98
+ is_mcp=data.get("is_mcp", True),
99
+ tags=data.get("tags", []),
100
+ timeout_secs=data.get("timeout_secs", 30.0),
101
+ )
102
+
103
+ @staticmethod
104
+ def _load_module(
105
+ path: Path, module_name: str, package: types.ModuleType | None = None
106
+ ) -> types.ModuleType:
107
+ spec = importlib.util.spec_from_file_location(
108
+ module_name,
109
+ path,
110
+ submodule_search_locations=[],
111
+ )
112
+ if spec is None or spec.loader is None:
113
+ raise ImportError(f"Cannot load module from {path}")
114
+ module = importlib.util.module_from_spec(spec)
115
+ if package is not None:
116
+ module.__package__ = package.__name__
117
+ import sys
118
+ sys.modules[module_name] = module
119
+ spec.loader.exec_module(module) # type: ignore[union-attr]
120
+ return module
121
+
122
+ def _load_edit_handler(self, folder: Path, pkg: types.ModuleType | None = None):
123
+ edit_path = folder / "edit" / "handler.py"
124
+ if not edit_path.exists():
125
+ return None
126
+ mod = self._load_module(edit_path, f"_skill_{folder.name}_edit", pkg)
127
+ return getattr(mod, "handle", None)
128
+
129
+ @staticmethod
130
+ def _load_examples(folder: Path) -> list[dict]:
131
+ examples_dir = folder / "examples"
132
+ if not examples_dir.exists():
133
+ return []
134
+ return [
135
+ json.loads(f.read_text())
136
+ for f in sorted(examples_dir.glob("*.json"))
137
+ ]
138
+
139
+ @staticmethod
140
+ def _load_defaults(folder: Path) -> dict | None:
141
+ p = folder / "defaults" / "input.json"
142
+ return json.loads(p.read_text()) if p.exists() else None
143
+
144
+
145
+ def _extract_docstring(path: Path) -> str | None:
146
+ try:
147
+ tree = ast.parse(path.read_text())
148
+ return ast.get_docstring(tree)
149
+ except Exception:
150
+ return None
harnessapi/edit.py ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import textwrap
4
+ import types
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pydantic import BaseModel
8
+
9
+ if TYPE_CHECKING:
10
+ from .skill import Skill
11
+
12
+
13
+ class EditRequest(BaseModel):
14
+ source_code: str
15
+ persist: bool = False
16
+
17
+
18
+ class EditResponse(BaseModel):
19
+ status: str
20
+ skill_name: str
21
+ error: str | None = None
22
+
23
+
24
+ def apply_edit(skill: Skill, request: EditRequest) -> None:
25
+ """Compile source_code and hot-swap skill.edit_handler.
26
+
27
+ Executes arbitrary Python — only expose this endpoint with auth in production.
28
+ """
29
+ source = textwrap.dedent(request.source_code)
30
+ module = types.ModuleType(f"_skill_{skill.meta.name}_edit_runtime")
31
+ try:
32
+ exec(compile(source, "<edit>", "exec"), module.__dict__)
33
+ except SyntaxError as exc:
34
+ raise ValueError(f"Syntax error in submitted handler: {exc}") from exc
35
+
36
+ handle_fn = getattr(module, "handle", None)
37
+ if handle_fn is None:
38
+ raise ValueError("Submitted source must define a `handle` function")
39
+
40
+ skill.edit_handler = handle_fn
41
+
42
+ if request.persist and skill.folder is not None:
43
+ edit_dir = skill.folder / "edit"
44
+ edit_dir.mkdir(exist_ok=True)
45
+ (edit_dir / "handler.py").write_text(request.source_code)
@@ -0,0 +1,22 @@
1
+ class SkillAPIError(Exception):
2
+ """Base exception for all harnessapi errors."""
3
+
4
+
5
+ class SkillNotFoundError(SkillAPIError):
6
+ """Raised when a skill name cannot be resolved."""
7
+
8
+
9
+ class SkillValidationError(SkillAPIError):
10
+ """Raised when input validation fails against the skill's input model."""
11
+
12
+
13
+ class SkillConflictError(SkillAPIError):
14
+ """Raised when two skills with the same name are registered."""
15
+
16
+
17
+ class SkillHandlerError(SkillAPIError):
18
+ """Raised when a skill handler raises an unexpected exception."""
19
+
20
+
21
+ class EditNotAllowedError(SkillAPIError):
22
+ """Raised when an edit is attempted but the endpoint is disabled."""
harnessapi/mcp.py ADDED
@@ -0,0 +1,58 @@
1
+ import asyncio
2
+ from typing import Any
3
+
4
+ from fastmcp import FastMCP
5
+
6
+ from .skill import Skill
7
+
8
+
9
+ def build_mcp_server(name: str = "HarnessAPI") -> FastMCP:
10
+ return FastMCP(name=name)
11
+
12
+
13
+ def register_skill_as_mcp_tool(mcp: FastMCP, skill: Skill) -> None:
14
+ if not skill.meta.is_mcp:
15
+ return
16
+
17
+ _make_and_register(mcp, skill)
18
+
19
+
20
+ def _make_and_register(mcp: FastMCP, skill: Skill) -> None:
21
+ input_model = skill.input_model
22
+ is_streaming = skill.is_streaming_handler()
23
+ handler = skill.effective_handler
24
+ skill_name = skill.meta.name
25
+ skill_desc = skill.meta.description
26
+ timeout = skill.meta.timeout_secs
27
+
28
+ # Build the wrapper source so the annotation resolves at definition time.
29
+ # FastMCP 3.x inspects __annotations__ — the model must be in the function's
30
+ # global namespace, not just the enclosing scope.
31
+ globs = {
32
+ "asyncio": asyncio,
33
+ "Any": Any,
34
+ "input_model": input_model,
35
+ "handler": handler,
36
+ "is_streaming": is_streaming,
37
+ "timeout": timeout,
38
+ }
39
+ src = (
40
+ "async def mcp_wrapper(input: input_model) -> Any:\n"
41
+ " if is_streaming:\n"
42
+ " chunks = []\n"
43
+ " async for chunk in handler(input):\n"
44
+ " chunks.append(str(chunk))\n"
45
+ " return '\\n'.join(chunks)\n"
46
+ " else:\n"
47
+ " if timeout is not None:\n"
48
+ " result = await asyncio.wait_for(handler(input), timeout=timeout)\n"
49
+ " else:\n"
50
+ " result = await handler(input)\n"
51
+ " return result.model_dump()\n"
52
+ )
53
+ exec(compile(src, "<mcp_wrapper>", "exec"), globs)
54
+ mcp_wrapper = globs["mcp_wrapper"]
55
+ mcp_wrapper.__name__ = skill_name
56
+ mcp_wrapper.__doc__ = skill_desc
57
+
58
+ mcp.tool(name=skill_name, description=skill_desc)(mcp_wrapper)
harnessapi/models.py ADDED
@@ -0,0 +1,9 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+
4
+ class SkillInput(BaseModel):
5
+ model_config = ConfigDict(extra="forbid")
6
+
7
+
8
+ class SkillOutput(BaseModel):
9
+ model_config = ConfigDict(extra="forbid")
harnessapi/py.typed ADDED
File without changes
harnessapi/routing.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from fastapi import Request
6
+ from fastapi.responses import JSONResponse
7
+ from fastapi.routing import APIRoute
8
+
9
+ from .edit import EditRequest, EditResponse, apply_edit
10
+ from .streaming import make_sse_response
11
+
12
+ if TYPE_CHECKING:
13
+ from .skill import Skill
14
+
15
+
16
+ class SkillRoute(APIRoute):
17
+ """POST /skills/{name} — SSE by default, JSON when Accept: application/json."""
18
+
19
+ def __init__(self, skill: Skill, **kwargs) -> None:
20
+ self._skill = skill
21
+ endpoint = self._make_endpoint()
22
+ super().__init__(
23
+ path=f"/skills/{skill.meta.name}",
24
+ endpoint=endpoint,
25
+ methods=["POST"],
26
+ tags=skill.meta.tags or ["skills"],
27
+ summary=skill.meta.name,
28
+ description=skill.meta.description,
29
+ **kwargs,
30
+ )
31
+
32
+ def _make_endpoint(self):
33
+ skill = self._skill
34
+
35
+ async def endpoint(request: Request):
36
+ body = await request.json()
37
+ input_obj = skill.input_model.model_validate(body)
38
+ accept = request.headers.get("accept", "")
39
+ if "application/json" in accept:
40
+ if skill.is_streaming_handler():
41
+ chunks: list[str] = []
42
+ async for chunk in skill.effective_handler(input_obj):
43
+ chunks.append(str(chunk))
44
+ return JSONResponse(content={"chunks": chunks})
45
+ else:
46
+ import asyncio
47
+ timeout = skill.meta.timeout_secs
48
+ if timeout is not None:
49
+ result = await asyncio.wait_for(
50
+ skill.effective_handler(input_obj), timeout=timeout
51
+ )
52
+ else:
53
+ result = await skill.effective_handler(input_obj)
54
+ return JSONResponse(content=result.model_dump())
55
+ return make_sse_response(skill, input_obj)
56
+
57
+ endpoint.__name__ = f"skill_{skill.meta.name}"
58
+ return endpoint
59
+
60
+
61
+ class EditRoute(APIRoute):
62
+ """POST /skills/{name}/edit — hot-swap the skill handler."""
63
+
64
+ def __init__(self, skill: Skill, **kwargs) -> None:
65
+ self._skill = skill
66
+ endpoint = self._make_endpoint()
67
+ super().__init__(
68
+ path=f"/skills/{skill.meta.name}/edit",
69
+ endpoint=endpoint,
70
+ methods=["POST"],
71
+ tags=["skills", "edit"],
72
+ summary=f"Edit handler: {skill.meta.name}",
73
+ response_model=EditResponse,
74
+ **kwargs,
75
+ )
76
+
77
+ def _make_endpoint(self):
78
+ skill = self._skill
79
+
80
+ async def endpoint(body: EditRequest) -> EditResponse:
81
+ try:
82
+ apply_edit(skill, body)
83
+ return EditResponse(status="ok", skill_name=skill.meta.name)
84
+ except ValueError as exc:
85
+ return EditResponse(status="error", skill_name=skill.meta.name, error=str(exc))
86
+
87
+ endpoint.__name__ = f"edit_{skill.meta.name}"
88
+ return endpoint
harnessapi/skill.py ADDED
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any, Callable
7
+
8
+ if TYPE_CHECKING:
9
+ from .models import SkillInput, SkillOutput
10
+
11
+
12
+ @dataclass
13
+ class SkillMeta:
14
+ name: str
15
+ description: str
16
+ is_mcp: bool = True
17
+ tags: list[str] = field(default_factory=list)
18
+ timeout_secs: float | None = 30.0
19
+
20
+
21
+ @dataclass
22
+ class Skill:
23
+ meta: SkillMeta
24
+ input_model: type[SkillInput]
25
+ output_model: type[SkillOutput]
26
+ handler: Callable
27
+ edit_handler: Callable | None
28
+ folder: Path | None
29
+ examples: list[dict[str, Any]] = field(default_factory=list)
30
+ defaults: dict[str, Any] | None = None
31
+
32
+ @property
33
+ def effective_handler(self) -> Callable:
34
+ return self.edit_handler if self.edit_handler is not None else self.handler
35
+
36
+ def is_streaming_handler(self) -> bool:
37
+ return inspect.isasyncgenfunction(self.effective_handler)
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import AsyncGenerator
6
+ from typing import TYPE_CHECKING
7
+
8
+ from sse_starlette.sse import EventSourceResponse, ServerSentEvent
9
+
10
+ if TYPE_CHECKING:
11
+ from .skill import Skill
12
+ from .models import SkillInput
13
+
14
+
15
+ async def skill_sse_generator(
16
+ skill: Skill,
17
+ input_obj: SkillInput,
18
+ ) -> AsyncGenerator[ServerSentEvent, None]:
19
+ handler = skill.effective_handler
20
+ timeout = skill.meta.timeout_secs
21
+ try:
22
+ if skill.is_streaming_handler():
23
+ async def _stream():
24
+ async for chunk in handler(input_obj):
25
+ yield ServerSentEvent(data=str(chunk), event="chunk")
26
+ yield ServerSentEvent(data="", event="done")
27
+
28
+ async for event in _stream():
29
+ yield event
30
+ else:
31
+ if timeout is not None:
32
+ result = await asyncio.wait_for(handler(input_obj), timeout=timeout)
33
+ else:
34
+ result = await handler(input_obj)
35
+ yield ServerSentEvent(data=result.model_dump_json(), event="result")
36
+ yield ServerSentEvent(data="", event="done")
37
+ except asyncio.TimeoutError:
38
+ yield ServerSentEvent(data=f"Skill '{skill.meta.name}' timed out", event="error")
39
+ except Exception as exc:
40
+ yield ServerSentEvent(data=str(exc), event="error")
41
+
42
+
43
+ def make_sse_response(skill: Skill, input_obj: SkillInput) -> EventSourceResponse:
44
+ return EventSourceResponse(skill_sse_generator(skill, input_obj))
@@ -0,0 +1,284 @@
1
+ Metadata-Version: 2.4
2
+ Name: harnessapi
3
+ Version: 0.1.0
4
+ Summary: Skill-first API framework: every endpoint is also an MCP tool
5
+ License-File: LICENSE
6
+ Keywords: agents,fastapi,llm,mcp,skills,sse,streaming
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Framework :: FastAPI
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
15
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: anyio>=4.0.0
18
+ Requires-Dist: fastapi>=0.115.0
19
+ Requires-Dist: fastmcp>=2.0.0
20
+ Requires-Dist: pydantic>=2.0.0
21
+ Requires-Dist: sse-starlette>=2.0.0
22
+ Requires-Dist: uvicorn[standard]>=0.30.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: httpx>=0.27; extra == 'dev'
25
+ Requires-Dist: mypy>=1.10; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.4; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # harnessapi
32
+
33
+ **Skill-first API framework** — define a skill once, get both an HTTP endpoint and an MCP tool automatically.
34
+
35
+ Every skill is a folder. Drop in a `handler.py` and `models.py` and you have:
36
+ - `POST /skills/{name}` — streaming SSE by default, JSON on request
37
+ - An MCP tool at `/mcp` — ready for Claude Desktop, Cursor, or any MCP client
38
+
39
+ ---
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ uv add harnessapi
45
+ ```
46
+
47
+ Or clone and run locally:
48
+
49
+ ```bash
50
+ git clone <repo>
51
+ cd harnessapi
52
+ uv sync
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Quickstart
58
+
59
+ Create your skill folder:
60
+
61
+ ```
62
+ my_project/
63
+ ├── main.py
64
+ └── skills/
65
+ └── greet/
66
+ ├── models.py
67
+ └── handler.py
68
+ ```
69
+
70
+ **`skills/greet/models.py`**
71
+ ```python
72
+ from harnessapi import SkillInput, SkillOutput
73
+
74
+ class Input(SkillInput):
75
+ name: str
76
+
77
+ class Output(SkillOutput):
78
+ message: str
79
+ ```
80
+
81
+ **`skills/greet/handler.py`**
82
+ ```python
83
+ """Say hello to someone."""
84
+ from .models import Input, Output
85
+
86
+ async def handle(input: Input) -> Output:
87
+ return Output(message=f"Hello, {input.name}!")
88
+ ```
89
+
90
+ **`main.py`**
91
+ ```python
92
+ from pathlib import Path
93
+ from harnessapi import HarnessAPI
94
+
95
+ app = HarnessAPI(skills_dir=Path(__file__).parent / "skills")
96
+ ```
97
+
98
+ Run it exactly like a FastAPI app:
99
+
100
+ ```bash
101
+ uvicorn main:app --reload
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Try the factorial example
107
+
108
+ The repo ships with a streaming factorial skill that demonstrates SSE + MCP together.
109
+
110
+ ```bash
111
+ # from the repo root
112
+ uv run uvicorn examples.factorial_app.main:app --reload
113
+ ```
114
+
115
+ ### Call via HTTP — SSE stream (default)
116
+
117
+ ```bash
118
+ curl -X POST http://localhost:8000/skills/factorial \
119
+ -H "Content-Type: application/json" \
120
+ -d '{"n": 5}'
121
+ ```
122
+
123
+ Response (each multiplication step streamed as it's computed):
124
+
125
+ ```
126
+ event: chunk
127
+ data: start: 1
128
+
129
+ event: chunk
130
+ data: 2: 2
131
+
132
+ event: chunk
133
+ data: 3: 6
134
+
135
+ event: chunk
136
+ data: 4: 24
137
+
138
+ event: chunk
139
+ data: 5: 120
140
+
141
+ event: done
142
+ data:
143
+ ```
144
+
145
+ ### Call via HTTP — plain JSON
146
+
147
+ ```bash
148
+ curl -X POST http://localhost:8000/skills/factorial \
149
+ -H "Content-Type: application/json" \
150
+ -H "Accept: application/json" \
151
+ -d '{"n": 5}'
152
+ ```
153
+
154
+ ```json
155
+ {"chunks": ["start: 1", "2: 2", "3: 6", "4: 24", "5: 120"]}
156
+ ```
157
+
158
+ ### Connect an MCP client
159
+
160
+ The MCP server is automatically available at:
161
+
162
+ ```
163
+ http://localhost:8000/mcp
164
+ ```
165
+
166
+ Add it to **Claude Desktop** (`claude_desktop_config.json`):
167
+
168
+ ```json
169
+ {
170
+ "mcpServers": {
171
+ "harnessapi": {
172
+ "url": "http://localhost:8000/mcp"
173
+ }
174
+ }
175
+ }
176
+ ```
177
+
178
+ Or add it to **Cursor** settings under MCP servers.
179
+
180
+ The `factorial` skill is automatically registered as an MCP tool with parameter `input.n: int`.
181
+
182
+ ---
183
+
184
+ ## Skill folder structure
185
+
186
+ ```
187
+ skills/
188
+ └── my_skill/
189
+ ├── handler.py # REQUIRED — async def handle(input: Input) -> Output
190
+ ├── models.py # REQUIRED — class Input(SkillInput), class Output(SkillOutput)
191
+ ├── skill.toml # optional — metadata
192
+ ├── defaults/
193
+ │ └── input.json # optional — default values shown in OpenAPI docs
194
+ └── examples/
195
+ └── 01.json # optional — {input: {...}, output: {...}} example pairs
196
+ ```
197
+
198
+ **`skill.toml`** (all optional):
199
+ ```toml
200
+ [skill]
201
+ description = "What this skill does"
202
+ is_mcp = true # expose as MCP tool (default: true)
203
+ tags = ["math"]
204
+ timeout_secs = 30
205
+ ```
206
+
207
+ ### Streaming vs non-streaming
208
+
209
+ Return a value → non-streaming (single `result` SSE event):
210
+ ```python
211
+ async def handle(input: Input) -> Output:
212
+ return Output(...)
213
+ ```
214
+
215
+ Use `yield` → streaming (multiple `chunk` SSE events):
216
+ ```python
217
+ async def handle(input: Input):
218
+ for item in compute_steps(input):
219
+ yield item
220
+ ```
221
+
222
+ ---
223
+
224
+ ## Decorator API (no folder needed)
225
+
226
+ ```python
227
+ from harnessapi import HarnessAPI, SkillInput, SkillOutput, skill
228
+
229
+ class TranslateInput(SkillInput):
230
+ text: str
231
+ target_lang: str = "es"
232
+
233
+ class TranslateOutput(SkillOutput):
234
+ translated: str
235
+
236
+ @skill(
237
+ name="translate",
238
+ input_model=TranslateInput,
239
+ output_model=TranslateOutput,
240
+ is_mcp=True,
241
+ )
242
+ async def translate_handler(input: TranslateInput) -> TranslateOutput:
243
+ return TranslateOutput(translated=f"[{input.target_lang}] {input.text}")
244
+
245
+ app = HarnessAPI(title="My Skills")
246
+ ```
247
+
248
+ ---
249
+
250
+ ## Runtime edit endpoint (advanced)
251
+
252
+ Enable hot-swapping a skill's handler over HTTP:
253
+
254
+ ```python
255
+ app = HarnessAPI(skills_dir="./skills", enable_edit_endpoints=True)
256
+ ```
257
+
258
+ ```bash
259
+ curl -X POST http://localhost:8000/skills/factorial/edit \
260
+ -H "Content-Type: application/json" \
261
+ -d '{
262
+ "source_code": "async def handle(input):\n yield f\"custom: {input.n}\"",
263
+ "persist": false
264
+ }'
265
+ ```
266
+
267
+ > **Security note**: The edit endpoint executes arbitrary Python. Always protect it with authentication middleware in production. It is disabled by default.
268
+
269
+ ---
270
+
271
+ ## SSE event protocol
272
+
273
+ | Event | When |
274
+ |----------|-------------------------------------------|
275
+ | `chunk` | Each yielded value from a streaming handler |
276
+ | `result` | The final output of a non-streaming handler |
277
+ | `done` | Always the last event |
278
+ | `error` | Handler raised an exception |
279
+
280
+ ---
281
+
282
+ ## OpenAPI docs
283
+
284
+ Interactive docs are available at `http://localhost:8000/docs` — all skills appear as documented POST endpoints with their Pydantic schemas.
@@ -0,0 +1,16 @@
1
+ harnessapi/__init__.py,sha256=EIZQEF2_rMhczZxn85rk2KTj8eAGkQUrsQLQeZQCkXg,573
2
+ harnessapi/app.py,sha256=v7ynjYn1WVLchW32XRUQVoI36kb-IAQlprJcq5lu0CE,2620
3
+ harnessapi/decorators.py,sha256=lYmOfU5ai3SCvQlwrq7-Y33s1ZO7ecy5fyducMxs1pg,2163
4
+ harnessapi/discovery.py,sha256=oDcb3octkvnVme0_uAgdsGFARrcSyDeVjp7NVuH1VPg,5392
5
+ harnessapi/edit.py,sha256=LCX_9QgdwRHzsaEvpQEXU1E2mlqzAFrL4ZpBNL890xM,1264
6
+ harnessapi/exceptions.py,sha256=Deiimh7ouYRD7lBjW3jpy3hRRD0P8ZjO44MK4yTlA-I,645
7
+ harnessapi/mcp.py,sha256=8xQ1kFiJoTp2capJ7FRUzxZOcm6AAaXsCYhM8lI-QmU,1859
8
+ harnessapi/models.py,sha256=NVwYVMIBpaWyZaISxAHzq7iOw0oDbrg8217iLdTuPh4,198
9
+ harnessapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ harnessapi/routing.py,sha256=6H4gjJh9XhK2oR0OR2DQ0PGg-L6kz6njtWP28NmynHk,3078
11
+ harnessapi/skill.py,sha256=9k5UuNqn6CpLacywUTIBjsmkfzkbVi8ytcyEqUXKBrY,968
12
+ harnessapi/streaming.py,sha256=gKVTfIhTotWkRCEY6MgdH2LKFDqC-zHl7fUpHhnWa6Y,1540
13
+ harnessapi-0.1.0.dist-info/METADATA,sha256=T6J-01cyXJwQggI_s26ah4QwvRgl40EEqFkixh_Pb3M,6455
14
+ harnessapi-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ harnessapi-0.1.0.dist-info/licenses/LICENSE,sha256=0H9MlwWVGJvUdyMbKwuA1ptbwh11sC8Mq1WirKsyZhQ,1067
16
+ harnessapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Edwin Jose
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.