igni-extension 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.
@@ -0,0 +1,78 @@
1
+ """igni-extension: an extension's process, as its author writes it.
2
+
3
+ ext = IgniExtension("acme.example")
4
+
5
+ @ext.tool(hints=ToolHints(read_only=True, destructive=False, idempotent=True, open_world=False))
6
+ async def do_thing() -> str: ...
7
+
8
+ ext.run()
9
+
10
+ See the package README for the whole surface.
11
+ """
12
+
13
+ from igni_extension.caller import Caller, CallerKind, NoCaller
14
+ from igni_extension.environment import (
15
+ Assets,
16
+ ExtensionEnvironment,
17
+ HostEndpoint,
18
+ Secrets,
19
+ Workspace,
20
+ WorkspaceMode,
21
+ )
22
+ from igni_extension.errors import (
23
+ HostError,
24
+ HostErrorCode,
25
+ HostUnavailable,
26
+ IgniExtensionError,
27
+ NotConfigured,
28
+ NotSignedIn,
29
+ )
30
+ from igni_extension.extension import IgniExtension, ToolNames
31
+ from igni_extension.hints import ToolHints, ToolHintsMissing
32
+ from igni_extension.hooks import Allow, HookEvent, Hooks, Modify, Reject
33
+ from igni_extension.host import Host, Notifications
34
+ from igni_extension.host_model import SCHEMA_VERSION
35
+ from igni_extension.members import Property, Signal
36
+ from igni_extension.notifications import LocalNotification, Target
37
+ from igni_extension.prompt import PromptConditions
38
+ from igni_extension.service import ServiceClient
39
+ from igni_extension.service_events import ServiceEvent
40
+ from igni_extension.tokens import SignedIn
41
+
42
+ __all__ = [
43
+ "Allow",
44
+ "Assets",
45
+ "Caller",
46
+ "CallerKind",
47
+ "ExtensionEnvironment",
48
+ "Host",
49
+ "HostEndpoint",
50
+ "HostError",
51
+ "HostErrorCode",
52
+ "HookEvent",
53
+ "Hooks",
54
+ "HostUnavailable",
55
+ "IgniExtension",
56
+ "IgniExtensionError",
57
+ "LocalNotification",
58
+ "Modify",
59
+ "NoCaller",
60
+ "NotConfigured",
61
+ "NotSignedIn",
62
+ "Notifications",
63
+ "PromptConditions",
64
+ "Reject",
65
+ "Property",
66
+ "SCHEMA_VERSION",
67
+ "Secrets",
68
+ "ServiceClient",
69
+ "ServiceEvent",
70
+ "Signal",
71
+ "SignedIn",
72
+ "Target",
73
+ "ToolNames",
74
+ "ToolHints",
75
+ "ToolHintsMissing",
76
+ "Workspace",
77
+ "WorkspaceMode",
78
+ ]
@@ -0,0 +1,226 @@
1
+ """Who a call is for: `Caller`.
2
+
3
+ Every request igni sends this process — a tool call, a page's call, a
4
+ hook — names the producer it is for: a person (with their organisation
5
+ and the one group they belong to) or an app. A tool or hook handler that wants it declares a
6
+ parameter typed `Caller`, and the SDK passes it in; anything running
7
+ inside that call can also ask `Caller.current()`.
8
+
9
+ @ext.tool(hints=ToolHints(read_only=True, destructive=False, idempotent=True, open_world=False))
10
+ async def mine(caller: Caller) -> list[str]:
11
+ response = await ext.service.for_(caller).get("/v1/things")
12
+ ...
13
+
14
+ A tool typed `caller: Caller` fails when igni sent no caller; one typed
15
+ `caller: Caller | None` runs with `None` instead.
16
+
17
+ The extension never decides who may call it: igni only forwards a call
18
+ the producer may make. Its job is to serve the caller it is handed — and
19
+ to keep callers apart, which is why the caller lives in the request, not
20
+ in a global. Concurrent calls each see their own (`contextvars`), and a
21
+ plain `def` running in a worker thread sees its call's caller too.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import inspect
28
+ from collections.abc import Callable, Iterator
29
+ from contextvars import ContextVar
30
+ from enum import StrEnum
31
+ from functools import wraps
32
+ from typing import Any, ClassVar, get_type_hints
33
+
34
+ from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
35
+ from pydantic import BaseModel, ConfigDict, ValidationError
36
+
37
+
38
+ class CallerKind(StrEnum):
39
+ PERSON = "person"
40
+ APP = "app"
41
+ #: One end user of an app, a producer of its own.
42
+ SPACE = "space"
43
+
44
+
45
+ class NoCaller(LookupError):
46
+ """Asked for the caller outside a call igni made: in `on_start` work, say."""
47
+
48
+
49
+ class Caller(BaseModel):
50
+ """The producer one call is for."""
51
+
52
+ model_config = ConfigDict(frozen=True)
53
+
54
+ #: Where the host puts it: the MCP request's `_meta` key, and the hook frame's field.
55
+ META_KEY: ClassVar[str] = "igni/principal"
56
+ #: How a token request names whom the token is for.
57
+ TOKEN_HEADER: ClassVar[str] = "X-Igni-Principal" # noqa: S105 — a header's name, not a secret
58
+
59
+ kind: CallerKind
60
+ #: The person's or the app's id on igni server.
61
+ id: str
62
+ #: Their organisation.
63
+ org: str | None = None
64
+ #: The one group a person belongs to in `org`; an app has none.
65
+ group: str | None = None
66
+ #: The igni session the call comes from, when there is one.
67
+ session: str | None = None
68
+ #: A space's app (its id); `None` for a person or an app.
69
+ app: str | None = None
70
+ #: The app's own name for a space's end user.
71
+ external_id: str | None = None
72
+
73
+ @property
74
+ def ref(self) -> str:
75
+ """`person:<id>` — how the host's token endpoint is told whom a token is for."""
76
+ return f"{self.kind.value}:{self.id}"
77
+
78
+ @classmethod
79
+ def of(cls, raw: Any) -> Caller | None:
80
+ """A caller from the wire, or `None` when there is none or it is malformed."""
81
+ if not isinstance(raw, dict):
82
+ return None
83
+ try:
84
+ return cls.model_validate(raw)
85
+ except ValidationError:
86
+ return None
87
+
88
+ @classmethod
89
+ def current(cls) -> Caller | None:
90
+ """The caller of the call being served now, or `None` outside one."""
91
+ scoped = _SCOPE.get()
92
+ if scoped is not None:
93
+ return scoped
94
+ meta = RequestMeta.current()
95
+ return cls.of(meta.get(cls.META_KEY)) if meta is not None else None
96
+
97
+ @classmethod
98
+ def required(cls) -> Caller:
99
+ """The current caller; `NoCaller` outside a call igni made."""
100
+ caller = cls.current()
101
+ if caller is None:
102
+ raise NoCaller(
103
+ "no caller: this runs outside a call igni made, or igni sent none. "
104
+ "Work with no caller uses the service as the extension itself."
105
+ )
106
+ return caller
107
+
108
+
109
+ #: The caller of the call being served, where the SDK set it itself (a hook).
110
+ _SCOPE: ContextVar[Caller | None] = ContextVar("igni_caller", default=None)
111
+
112
+ #: The `_meta` of the MCP request being served, set by `RequestMeta.middleware`.
113
+ _META: ContextVar[dict[str, Any] | None] = ContextVar("igni_request_meta", default=None)
114
+
115
+
116
+ class RequestMeta:
117
+ """The `_meta` of the MCP request this code is serving, wherever it runs.
118
+
119
+ Set for the length of each request by `middleware`, which the
120
+ extension's server runs on every message igni sends, so a tool — and
121
+ anything it calls, on the loop or in a worker thread — reads the
122
+ request it serves: concurrent calls each see their own.
123
+ """
124
+
125
+ @staticmethod
126
+ def current() -> dict[str, Any] | None:
127
+ """The current request's `_meta`, or `None` outside a request (or when it had none)."""
128
+ return _META.get()
129
+
130
+ @staticmethod
131
+ async def middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
132
+ """An MCP server middleware: the request's `_meta` is current while it is served."""
133
+ token = _META.set(dict(ctx.meta) if ctx.meta else None)
134
+ try:
135
+ return await call_next(ctx)
136
+ finally:
137
+ _META.reset(token)
138
+
139
+
140
+ class CallerScope:
141
+ """Sets the caller for the call being served, and finds where a function wants it."""
142
+
143
+ @staticmethod
144
+ @contextlib.contextmanager
145
+ def of(caller: Caller | None) -> Iterator[None]:
146
+ token = _SCOPE.set(caller)
147
+ try:
148
+ yield
149
+ finally:
150
+ _SCOPE.reset(token)
151
+
152
+ @staticmethod
153
+ def parameter(fn: Callable[..., Any]) -> str | None:
154
+ """The name of `fn`'s parameter typed `Caller` (or `Caller | None`), if it has one."""
155
+ try:
156
+ hints = get_type_hints(fn)
157
+ except (NameError, TypeError):
158
+ hints = getattr(fn, "__annotations__", {})
159
+ for name, annotation in hints.items():
160
+ if name != "return" and annotation in (Caller, Caller | None):
161
+ return name
162
+ return None
163
+
164
+ @staticmethod
165
+ def optional(fn: Callable[..., Any], name: str) -> bool:
166
+ """Whether `fn` takes `Caller | None` there: it runs without one, rather than failing."""
167
+ try:
168
+ return bool(get_type_hints(fn).get(name) == Caller | None)
169
+ except (NameError, TypeError):
170
+ return False
171
+
172
+ @classmethod
173
+ def inject(cls, fn: Callable[..., Any]) -> Callable[..., Any]:
174
+ """`fn` with its `Caller` parameter filled from the call, and hidden from its schema.
175
+
176
+ The parameter is not something igni or a model supplies, so it
177
+ is not part of the tool's input: the wrapper's signature leaves
178
+ it out, and the schema derived from it does too.
179
+ """
180
+ name = cls.parameter(fn)
181
+ if name is None:
182
+ return fn
183
+ find: Callable[[], Caller | None] = (
184
+ Caller.current if cls.optional(fn, name) else Caller.required
185
+ )
186
+ signature = inspect.signature(fn)
187
+ # Resolved now, against the author's module: the wrapper lives in
188
+ # this one, and a string annotation evaluated here would not find
189
+ # the author's types.
190
+ try:
191
+ resolved = get_type_hints(fn)
192
+ except (NameError, TypeError):
193
+ resolved = dict(getattr(fn, "__annotations__", {}))
194
+ visible = [
195
+ p.replace(annotation=resolved.get(p.name, p.annotation))
196
+ for p in signature.parameters.values()
197
+ if p.name != name
198
+ ]
199
+ hints = {k: v for k, v in resolved.items() if k != name}
200
+ return_annotation = resolved.get("return", signature.return_annotation)
201
+
202
+ if inspect.iscoroutinefunction(fn):
203
+
204
+ @wraps(fn)
205
+ async def with_caller_async(*args: Any, **kwargs: Any) -> Any:
206
+ return await fn(*args, **kwargs, **{name: find()})
207
+
208
+ wrapper: Callable[..., Any] = with_caller_async
209
+ else:
210
+
211
+ @wraps(fn)
212
+ def with_caller(*args: Any, **kwargs: Any) -> Any:
213
+ return fn(*args, **kwargs, **{name: find()})
214
+
215
+ wrapper = with_caller
216
+ wrapper.__signature__ = signature.replace( # type: ignore[attr-defined]
217
+ parameters=visible, return_annotation=return_annotation
218
+ )
219
+ wrapper.__annotations__ = hints
220
+ # `wraps` points `__wrapped__` at `fn`, and `inspect.signature`
221
+ # follows it back to the hidden parameter.
222
+ del wrapper.__wrapped__ # type: ignore[attr-defined]
223
+ return wrapper
224
+
225
+
226
+ __all__ = ["Caller", "CallerKind", "CallerScope", "NoCaller", "RequestMeta"]
@@ -0,0 +1,295 @@
1
+ """The socket to the igni host: how `ext.host` reaches igni.
2
+
3
+ igni starts an extension's process with `IGNI_HOST_URL` and
4
+ `IGNI_HOST_SECRET`: a WebSocket on the host's loopback interface and the
5
+ secret that says which process this is. Every `get`, `set` and `call` on
6
+ `ext.host` is one request on it, and every signal the extension handles
7
+ arrives on it. The protocol is igni's `docs/HOST_MODEL_PROTOCOL.md`; an
8
+ extension author never sees it.
9
+
10
+ Started without those variables — run outside igni, or by an igni older
11
+ than the host model — `ext.host` raises `NotConfigured`, except
12
+ `ext.host.notifications.notify`, which then travels as the MCP
13
+ notification igni has always understood.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import contextlib
20
+ import itertools
21
+ import json
22
+ import logging
23
+ import os
24
+ from typing import Any
25
+
26
+ from websockets.asyncio.client import ClientConnection, connect
27
+ from websockets.exceptions import WebSocketException
28
+
29
+ from igni_extension.environment import HostEndpoint
30
+ from igni_extension.errors import HostError, HostUnavailable, NotConfigured
31
+ from igni_extension.hooks import HookHandlers
32
+ from igni_extension.notifications import LocalNotification, NotifyNotification
33
+ from igni_extension.service_events import SERVICE_EVENT, ServiceEventHandlers
34
+ from igni_extension.session import HostSession
35
+ from igni_extension.signals import SignalHandlers
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ #: The one member that still works without the socket.
40
+ NOTIFY = "notifications.notify"
41
+
42
+
43
+ #: Members whose answer is the person's, so no timeout applies to them.
44
+ WAITS_ON_THE_PERSON = frozenset({"ui.show_dialog"})
45
+
46
+
47
+ class HostConnection:
48
+ """One extension process's connection to the igni host."""
49
+
50
+ #: How long a request waits for its answer.
51
+ TIMEOUT = 120.0
52
+ #: Between attempts to reach a host that is not answering yet.
53
+ RETRY_SECONDS = 2.0
54
+
55
+ def __init__(
56
+ self,
57
+ endpoint: HostEndpoint,
58
+ signals: SignalHandlers,
59
+ session: HostSession | None = None,
60
+ hooks: HookHandlers | None = None,
61
+ service_events: ServiceEventHandlers | None = None,
62
+ ) -> None:
63
+ self._endpoint = endpoint
64
+ self._signals = signals
65
+ self._hooks = hooks if hooks is not None else HookHandlers()
66
+ self._service_events = (
67
+ service_events if service_events is not None else ServiceEventHandlers()
68
+ )
69
+ self._session = session
70
+ self._socket: ClientConnection | None = None
71
+ self._reader: asyncio.Task[None] | None = None
72
+ self._lock = asyncio.Lock()
73
+ self._ids = itertools.count(1)
74
+ self._waiting: dict[int, asyncio.Future[Any]] = {}
75
+ self._hello: asyncio.Future[dict[str, Any]] | None = None
76
+ self._tasks: set[asyncio.Task[Any]] = set()
77
+ #: What the host said this extension holds, after connecting.
78
+ self.granted: list[str] = []
79
+ self.schema_version = ""
80
+ signals.when_new(self._new_signal)
81
+
82
+ @property
83
+ def configured(self) -> bool:
84
+ return self._endpoint.configured
85
+
86
+ @property
87
+ def connected(self) -> bool:
88
+ return self._socket is not None
89
+
90
+ # ── HostLink ──────────────────────────────────────────────────
91
+
92
+ async def request(
93
+ self,
94
+ op: str,
95
+ path: str,
96
+ *,
97
+ window: str | None = None,
98
+ session: str | None = None,
99
+ params: dict[str, Any] | None = None,
100
+ value: Any = None,
101
+ ) -> Any:
102
+ if not self.configured:
103
+ if op == "call" and path == NOTIFY and self._session is not None:
104
+ notification = LocalNotification.model_validate((params or {})["notification"])
105
+ await self._session.send(NotifyNotification.of(notification))
106
+ return None
107
+ raise NotConfigured(
108
+ f"host.{path}: igni did not give this process the host's address "
109
+ "(IGNI_HOST_URL, IGNI_HOST_SECRET) — it is not running inside igni"
110
+ )
111
+ await self.connect()
112
+ body: dict[str, Any] = {"op": op, "path": path}
113
+ if window is not None:
114
+ body["window"] = window
115
+ if session is not None:
116
+ body["session"] = session
117
+ if params:
118
+ body["params"] = params
119
+ if op == "set":
120
+ body["value"] = value
121
+ return await self._send(body)
122
+
123
+ # ── The socket ────────────────────────────────────────────────
124
+
125
+ def _headers(self) -> dict[str, str]:
126
+ """The secret, and — where there is one — this process's group.
127
+
128
+ igni ends the group if the process hangs and it has to restart
129
+ it; it checks that it started the group's leader first.
130
+ """
131
+ headers = {"Authorization": f"Bearer {self._endpoint.secret}"}
132
+ if hasattr(os, "getpgid"):
133
+ headers["X-Igni-Process-Group"] = str(os.getpgid(0))
134
+ return headers
135
+
136
+ async def connect(self) -> None:
137
+ """Connect and say which signals are handled. Idempotent."""
138
+ async with self._lock:
139
+ if self._socket is not None:
140
+ return
141
+ try:
142
+ socket = await connect(
143
+ self._endpoint.url,
144
+ additional_headers=self._headers(),
145
+ max_size=8 * 1024 * 1024,
146
+ compression=None,
147
+ )
148
+ except (OSError, WebSocketException) as exc:
149
+ raise HostUnavailable(f"the igni host did not answer: {exc}") from exc
150
+ self._socket = socket
151
+ loop = asyncio.get_running_loop()
152
+ self._hello = loop.create_future()
153
+ self._reader = asyncio.create_task(self._read(socket))
154
+ try:
155
+ hello = await asyncio.wait_for(asyncio.shield(self._hello), self.TIMEOUT)
156
+ except TimeoutError as exc:
157
+ await self._drop()
158
+ raise HostUnavailable("the igni host did not say hello") from exc
159
+ self.granted = list(hello.get("granted") or [])
160
+ self.schema_version = str(hello.get("schema_version") or "")
161
+ wires = self._signals.wires()
162
+ if wires:
163
+ await self._subscribe(wires)
164
+
165
+ async def serve_forever(self) -> None:
166
+ """Stay connected while the process runs, so signals keep arriving."""
167
+ if not self.configured:
168
+ return
169
+ while True:
170
+ try:
171
+ await self.connect()
172
+ except HostUnavailable as exc:
173
+ logger.debug("igni: %s; trying again", exc)
174
+ await asyncio.sleep(self.RETRY_SECONDS)
175
+ continue
176
+ reader = self._reader
177
+ if reader is not None:
178
+ with contextlib.suppress(Exception):
179
+ await asyncio.shield(reader)
180
+ await asyncio.sleep(self.RETRY_SECONDS)
181
+
182
+ async def close(self) -> None:
183
+ await self._drop()
184
+ for task in list(self._tasks):
185
+ task.cancel()
186
+
187
+ async def _send(self, body: dict[str, Any]) -> Any:
188
+ socket = self._socket
189
+ if socket is None:
190
+ raise HostUnavailable("not connected to the igni host")
191
+ request_id = next(self._ids)
192
+ future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
193
+ self._waiting[request_id] = future
194
+ try:
195
+ await socket.send(json.dumps({"id": request_id, **body}))
196
+ # A dialog waits on the person, for as long as they take.
197
+ limit = None if body.get("path") in WAITS_ON_THE_PERSON else self.TIMEOUT
198
+ reply = await asyncio.wait_for(future, limit)
199
+ except (OSError, WebSocketException) as exc:
200
+ raise HostUnavailable(f"the igni host went away: {exc}") from exc
201
+ except TimeoutError as exc:
202
+ raise HostUnavailable(f"host.{body.get('path')} got no answer") from exc
203
+ finally:
204
+ self._waiting.pop(request_id, None)
205
+ if reply.get("ok"):
206
+ return reply.get("result")
207
+ error = reply.get("error") or {}
208
+ raise HostError(str(error.get("code") or "failed"), str(error.get("message") or "refused"))
209
+
210
+ async def _subscribe(self, wires: list[str]) -> None:
211
+ outcome = await self._send({"op": "subscribe", "signals": wires})
212
+ if isinstance(outcome, dict):
213
+ for wire in outcome.get("not_granted") or []:
214
+ logger.warning(
215
+ "igni: %s will never arrive — its object is not in permissions.host", wire
216
+ )
217
+ for wire in outcome.get("unknown") or []:
218
+ logger.warning("igni: this igni has no signal %s", wire)
219
+
220
+ def _new_signal(self, wire: str) -> None:
221
+ if self._socket is None:
222
+ return # sent with the rest on connect
223
+ self._spawn(self._subscribe([wire]))
224
+
225
+ async def _read(self, socket: ClientConnection) -> None:
226
+ try:
227
+ async for raw in socket:
228
+ try:
229
+ message = json.loads(raw)
230
+ except ValueError:
231
+ continue
232
+ op = message.get("op")
233
+ if op == "hello" and self._hello is not None and not self._hello.done():
234
+ self._hello.set_result(message)
235
+ elif op == "reply":
236
+ waiter = self._waiting.get(message.get("id"))
237
+ if waiter is not None and not waiter.done():
238
+ waiter.set_result(message)
239
+ elif op == "signal":
240
+ # A task, not an await: a handler that reads the model
241
+ # needs this loop free to receive the answer.
242
+ self._spawn(
243
+ self._signals.deliver(str(message.get("signal")), message.get("value"))
244
+ )
245
+ elif op == "hook":
246
+ # Off the read loop, as a signal is handled; igni
247
+ # waits on a deciding one, against a deadline.
248
+ hook_id = message.get("id")
249
+ name = str(message.get("event"))
250
+ principal = message.get("principal")
251
+ if hook_id is None:
252
+ self._spawn(self._hooks.tell(name, message.get("value"), principal))
253
+ else:
254
+ self._spawn(
255
+ self._answer(str(hook_id), name, message.get("value"), principal)
256
+ )
257
+ except WebSocketException:
258
+ pass
259
+ finally:
260
+ if self._socket is socket:
261
+ self._socket = None
262
+ for waiter in self._waiting.values():
263
+ if not waiter.done():
264
+ waiter.set_exception(HostUnavailable("the igni host went away"))
265
+
266
+ async def _answer(self, hook_id: str, event: str, value: Any, principal: Any = None) -> None:
267
+ if event == SERVICE_EVENT:
268
+ params = await self._service_events.answer(hook_id, value, principal)
269
+ else:
270
+ params = await self._hooks.answer(hook_id, event, value, principal)
271
+ try:
272
+ await self._send({"op": "answer", "params": params})
273
+ except (HostError, HostUnavailable) as exc:
274
+ # Too late, or the host went away: the event went ahead or not
275
+ # without this answer, as igni decides for a silence.
276
+ logger.warning("igni: the answer to %s did not count: %s", event, exc)
277
+
278
+ async def _drop(self) -> None:
279
+ socket, self._socket = self._socket, None
280
+ if socket is not None:
281
+ with contextlib.suppress(Exception):
282
+ await socket.close()
283
+ if self._reader is not None:
284
+ self._reader.cancel()
285
+ with contextlib.suppress(BaseException):
286
+ await self._reader
287
+ self._reader = None
288
+
289
+ def _spawn(self, coro: Any) -> None:
290
+ task = asyncio.create_task(coro)
291
+ self._tasks.add(task)
292
+ task.add_done_callback(self._tasks.discard)
293
+
294
+
295
+ __all__ = ["HostConnection"]
@@ -0,0 +1,12 @@
1
+ """Time, as one object, so tests can move it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+
8
+ class Clock:
9
+ """The system's monotonic clock: a token's age is measured on it."""
10
+
11
+ def monotonic(self) -> float:
12
+ return time.monotonic()