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 +27 -0
- harnessapi/app.py +78 -0
- harnessapi/decorators.py +75 -0
- harnessapi/discovery.py +150 -0
- harnessapi/edit.py +45 -0
- harnessapi/exceptions.py +22 -0
- harnessapi/mcp.py +58 -0
- harnessapi/models.py +9 -0
- harnessapi/py.typed +0 -0
- harnessapi/routing.py +88 -0
- harnessapi/skill.py +37 -0
- harnessapi/streaming.py +44 -0
- harnessapi-0.1.0.dist-info/METADATA +284 -0
- harnessapi-0.1.0.dist-info/RECORD +16 -0
- harnessapi-0.1.0.dist-info/WHEEL +4 -0
- harnessapi-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|
harnessapi/decorators.py
ADDED
|
@@ -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())
|
harnessapi/discovery.py
ADDED
|
@@ -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)
|
harnessapi/exceptions.py
ADDED
|
@@ -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
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)
|
harnessapi/streaming.py
ADDED
|
@@ -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,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.
|