{
  "markdown": "# thingctx\n\n[![CI](https://img.shields.io/github/actions/workflow/status/thingctx/thingctx/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/thingctx/thingctx/actions/workflows/ci.yml)\n[![PyPI](https://img.shields.io/pypi/v/thingctx?style=flat-square&label=PyPI&color=3775A9)](https://pypi.org/project/thingctx/)\n[![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB?style=flat-square)](https://pypi.org/project/thingctx/)\n[![License](https://img.shields.io/badge/License-Apache--2.0-4c9a2a?style=flat-square)](LICENSE)\n![W3C WoT](https://img.shields.io/badge/W3C_WoT-TD_1.1-005a9c?style=flat-square)\n\n![thingctx gating and routing a tool call](assets/hero.gif)\n\n**thingctx turns a description of any API, device, or local process into\ntools an AI agent can call: authorized per operation, over the system's own\ntransport, and the agent never holds your keys.** The description is a\n[W3C Web of Things](https://www.w3.org/WoT/) Thing Description (a TD), a\nplain JSON file naming what the system can do. Write one, or compile it from\nan OpenAPI spec. The described system is the server, so you run no server\nper integration.\n\nA \"Thing\" is whatever you want to describe: a REST API, an app, a sensor, a\nmedia server, a local object, or one system that is several of those at\nonce. A media server takes commands over HTTP and serves its stream over\nRTSP. The pump in [`examples/`](examples/) reads over MQTT and acts through\na local call. Each is one Thing and one description.\n\nA description names `actions`, `properties`, and `events`, and gives each one\na form: the entry that says where and how to reach it. The URL scheme in that\naddress picks the transport per call, so however many protocols one Thing\nspans, the agent still sees one tool set.\n\n[`docs/BINDINGS.md`](docs/BINDINGS.md) covers the transports and how to add\nyour own. A subprocess transport, `exec`, also ships, and it ships locked:\nit refuses every command until you hand it an explicit allowlist.\n\nThose three stay distinct all the way to the call. A read, a write, and a\nsubscribe do not collapse into one undifferentiated function, which is why\nthe gate can allow the read and refuse the write on the same system. A flat\nlist of functions has no such handle.\n\nPick your path: [use it as a\nlibrary](#use-it-as-a-library) if you own the agent loop, [the command\nline](#the-command-line) if you do not want to write code, [the MCP\nbridge](#the-mcp-bridge) if your host only speaks MCP, or [add a\ntransport](#extend-it) if thingctx does not speak your protocol yet.\n\nStuck, or wondering whether something is possible? Ask in\n[Discussions](https://github.com/thingctx/thingctx/discussions). Want to build\nsomething small? The [open issues](#contributing) say what would help.\n\n## First run: no keys, no network\n\nOne paste shows the whole idea. The description is inline and the handler\nships with the package, so nothing here needs a key, a network, or a second\nfile:\n\n```bash\npip install thingctx\n```\n\n```python\nimport asyncio\n\nimport thingctx\nfrom thingctx.contrib.time import make_time_handler\n\nTD = {\n    \"@context\": \"https://www.w3.org/2022/wot/td/v1.1\",\n    \"id\": \"urn:thingctx:time\",\n    \"title\": \"Time\",\n    \"securityDefinitions\": {\"nosec_sc\": {\"scheme\": \"nosec\"}},\n    \"security\": [\"nosec_sc\"],\n    \"actions\": {\n        \"getCurrentTime\": {\n            \"description\": \"Current time in an IANA timezone (default UTC).\",\n            \"input\": {\n                \"type\": \"object\",\n                \"properties\": {\"timezone\": {\"type\": \"string\"}},\n            },\n            \"safe\": True,\n            \"idempotent\": True,\n            \"forms\": [{\"href\": \"local://getCurrentTime\"}],\n        }\n    },\n}\n\n\nasync def main():\n    client = thingctx.ThingClient(tds=[TD], bindings=[thingctx.LocalBinding(make_time_handler())])\n    tools, invoke = client.as_tools()  # specs for your model; invoke runs a call\n    print(\"tools:\", [t[\"function\"][\"name\"] for t in tools])\n    print(await invoke(\"time__getCurrentTime\", {\"timezone\": \"UTC\"}))\n\n\nasyncio.run(main())\n```\n\n```\ntools: ['time__getCurrentTime']\n{'timezone': 'UTC', 'datetime': '2026-07-26T15:59:03.233080+00:00', 'utc_offset': '+0000'}\n```\n\nThat is the whole loop: a description in, tools out, calls routed.\n`LocalBinding` is a binding, a transport implementation; this one routes the\n`local://` address to the handler you pass it. Every other transport works\nthe same way; only the form's `href` changes.\n\n## Install\n\n```bash\npip install 'thingctx[llm,http,validate]'\n```\n\nQuote the argument; unquoted brackets fail in zsh. Base\n`pip install thingctx` has no dependencies; it already includes the `local`\nand `exec` transports. Redis support is `pip install 'thingctx[redis]'`. Every optional\ntransport and capability has an extra,\nlisted in [`pyproject.toml`](pyproject.toml); each one this page uses is\nnamed next to the code that needs it.\n\n## The document\n\nA description can be small. This one drives the live, key free\n[Open-Meteo](https://open-meteo.com) forecast API, so it runs as written.\nSave it as `weather.td.json`, a later example uses it:\n\n```json\n{\n  \"@context\": \"https://www.w3.org/2022/wot/td/v1.1\",\n  \"id\": \"urn:example:weather:v1\",\n  \"title\": \"Weather\",\n  \"securityDefinitions\": { \"nosec_sc\": { \"scheme\": \"nosec\" } },\n  \"security\": [\"nosec_sc\"],\n  \"actions\": {\n    \"forecast\": {\n      \"input\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"latitude\": { \"type\": \"number\" },\n          \"longitude\": { \"type\": \"number\" },\n          \"current\": { \"type\": \"string\" }\n        },\n        \"required\": [\"latitude\", \"longitude\"]\n      },\n      \"forms\": [{ \"href\": \"https://api.open-meteo.com/v1/forecast\", \"htv:methodName\": \"GET\" }]\n    }\n  }\n}\n```\n\nthingctx projects each action to a tool named `<thing>__<action>`: this file\nyields `weather__forecast` (a version segment on the id is dropped).\n[`docs/MAPPING.md`](docs/MAPPING.md) specifies the full projection. The\ndescription carries no secrets, so it is safe to commit and share. The\nsecret is supplied at runtime to the binding that carries the call. Secrets\nare keyed per Thing.\n\nIf your system already has an OpenAPI 3.x spec, you do not have to author a\ndescription at all:\n\n```bash\npip install 'thingctx[openapi]'\nthingctx import openapi https://api.example.com/openapi.json --out api.td.json\n```\n\nThe spec can be a file or a URL, JSON or YAML; `--base-url`, `--id`, and\n`--title` override what the spec says. The result is a normal description,\nso everything else here applies to it unchanged.\n\nReady made descriptions for common apps, developer tools, and devices live\nat [td.thingctx.com](https://td.thingctx.com).\n\n## Use it as a library\n\nOwn the agent loop? Read descriptions, hand the specs to your model, and\nroute each call back through `invoke`. `ThingClient` needs no LLM and has no\nopinion on what chose the action; any code can drive it. This sketch shows\nthe surface against a folder describing a pump; `from_arg` turns a path or\nURL into a registry of descriptions:\n\n```python\nimport thingctx\n\n\nasync def run():  # a sketch, not a program: call it from your own loop\n    client = thingctx.ThingClient.from_registry(thingctx.from_arg(\"./descriptions/\"))\n    specs, invoke = client.as_tools()\n\n    await invoke(\"pump__set_speed\", {\"rpm\": 1500})\n    await client.read_property(\"pump__rpm\")\n    await client.write_property(\"pump__target_rpm\", 1500)  # gated like invoke\n    async for evt in await client.subscribe(\n        \"pump__overheat\"\n    ):  # subscribe returns an async iterator\n        ...\n```\n\nThe form picks the transport per call, so one client can read over HTTP, subscribe\nover MQTT, and read or write cached properties over Redis. Bindings that pull optional\ndependencies, including MQTT and Redis, are off by default: install the extra and pass\n`bindings=thingctx.BindingRegistry.default(mqtt=True, redis=True)`.\n\nWant the loop handled for you? The `llm` extra runs any provider through\nlitellm; add the `http` extra, since the weather file's form is HTTPS. Set\nyour provider key in its usual variable (`OPENAI_API_KEY`,\n`ANTHROPIC_API_KEY`, and so on) and pick a model with `THINGCTX_MODEL`:\n\n```python\nimport asyncio\nimport thingctx\n\n\nasync def main():\n    # weather.td.json from above; it points at a live, key free API\n    host = thingctx.from_file(\"weather.td.json\")\n    print(await host.chat(\"What is the forecast for Cairo? Latitude 30.0, longitude 31.2.\"))\n\n\nasyncio.run(main())\n```\n\nThe model calls `weather__forecast` itself and answers in prose:\n\n```\nThe forecast for Cairo is clear, around 34 C.\n```\n\n## Safety: approval and authorization\n\nTwo opt in layers stand between an agent and a real system; both run\nbefore any transport is selected. Neither layer handles a credential. The\nbinding holds the secret and the model never sees it.\n\n**Approval** gates risky calls behind a human. Risk is read from the\ndescription, and when to gate is yours: `declared` (only actions the\ndescription marks risky; the default), `destructive` (adds non idempotent\nactions and every property write), `all`, or `never`. A gated call with no\napprover is denied: a gate with nobody to open it stays shut. The check sits\ninside `invoke` and `write_property`, so it covers the LLM loop, direct\ncallers, and the MCP bridge alike.\n\nCarrying on from the first example above, with the same `TD`:\n\n```python\ndef approve(req):  # sync or async; return True to allow\n    return input(f\"run {req.tool_name}{req.arguments}? [y/N] \").lower() == \"y\"\n\n\nclient = thingctx.ThingClient(\n    tds=[TD],\n    bindings=[thingctx.LocalBinding(make_time_handler())],\n    approve=approve,\n    approve_when=\"all\",\n)\n```\n\n**Authorization** decides from policy, per caller, per operation. Pass a\n`pdp` and an `identity` and every call authorizes the resolved\n`(thing, affordance, operation)` before it reaches the system; an affordance\nis an action, property, or event named in the description. This snippet condenses\n[`examples/14_authz.py`](examples/14_authz.py), which runs as is on the core\ninstall. It defines the `TD` and `Pump` used below: a pump with one\nproperty, `target_rpm`.\n\n```python\nfrom thingctx import LocalBinding, ThingClient\nfrom thingctx.authz import LocalPolicyGrantSource, PolicyDecisionPoint, build_vocabulary\n\nreader = ThingClient(\n    tds=[TD], bindings=[LocalBinding(Pump())]\n)  # no pdp yet; parsed only to read the description\nvocab = build_vocabulary(reader.things)  # the closed set the description declares\n\ngrants = LocalPolicyGrantSource({\"operator\": {(\"urn:demo:pump\", \"target_rpm\", \"readproperty\")}})\npdp = PolicyDecisionPoint(vocabulary=vocab, grant_source=grants)\nidentity = {\"sub\": \"alice\", \"roles\": [\"operator\"]}  # claims, validated upstream\n\nclient = ThingClient(tds=[TD], bindings=[LocalBinding(Pump())], pdp=pdp, identity=identity)\n```\n\nAlice's `operator` role grants read on `target_rpm` and nothing else, so her\nreads pass and her writes are refused. The vocabulary is closed: a grant is\nhonored only for operations the description declares. `thingctx.authz`\nimports no crypto and runs on the dependency free core. Identity is claims\nthat something upstream validated, never the credential itself.\n\nRunning the example prints:\n\n```\nvocabulary (grantable tuples the TD declares):\n  (urn:demo:pump, target_rpm, readproperty)\n  (urn:demo:pump, target_rpm, writeproperty)\n\nREAD  target_rpm  -> ALLOWED, device returned 1200\nWRITE target_rpm  -> DENIED, grant does not include ('urn:demo:pump', 'target_rpm', 'writeproperty')\n\nRE-READ target_rpm -> 1200  (unchanged: the denied write never ran)\n```\n\nThe full model is in [`docs/SECURITY.md`](docs/SECURITY.md).\n\n## The MCP bridge\n\nSome agents only take tools over MCP. For those, thingctx ships one generic\nMCP server, with no per integration server. It serves any registry of\ndescriptions: a folder, a URL, or a W3C Thing Description Directory. A\nDirectory is the standard catalog server for descriptions.\n\n```bash\npip install 'thingctx[mcp,http]'\nthingctx-mcp ./descriptions/\n```\n\nFor Claude Desktop, add this to\n`~/Library/Application Support/Claude/claude_desktop_config.json`\n(on Windows: `%APPDATA%\\Claude\\claude_desktop_config.json`) and restart:\n\n```json\n{ \"mcpServers\": { \"things\": {\n  \"command\": \"thingctx-mcp\",\n  \"args\": [\"/path/to/your/descriptions/\"],\n  \"env\": { \"THINGCTX_APPROVE_WHEN\": \"destructive\" } } } }\n```\n\nThe same approval gate applies here: the bridge marks risky tools and asks\nthe client to confirm. Declining denies the call. A client that cannot show\na confirmation dialog gets the call parked as pending instead, and the\nbridge's `approve` tool confirms it from the chat.\n`THINGCTX_APPROVE_WHEN` picks the policy, and `THINGCTX_POLICY` set to\n`read-only` denies writes and state changing actions outright.\n\nDescriptions never carry secrets, so the bridge reads them from the\nenvironment: `THINGCTX_TOKEN_<SLUG>` binds a secret to the Thing with that\nslug (`THINGCTX_TOKEN_GITHUB` serves `github`), applied per the Thing's\ndeclared scheme.\n\n## The command line\n\nThe `thingctx` command drives the same runtime from a terminal. Point `list`\nand `invoke` at any folder of descriptions. Save the TD from the first run\nabove as `things/time.td.json`, then:\n\n```bash\nthingctx list ./things/\nthingctx invoke ./things/ time__getCurrentTime --arg timezone=UTC\n```\n\n```\n{\n  \"timezone\": \"UTC\",\n  \"datetime\": \"2026-07-26T16:45:57.114817+00:00\",\n  \"utc_offset\": \"+0000\"\n}\n```\n\nThat works with no extra wiring because the clock's handler ships with the\npackage and binds to the `urn:thingctx:time` id in the description.\n\n`list` prints every tool the folder's Things expose, with its schema;\n`invoke` runs one action. `thingctx --help` lists the other subcommands,\nincluding the OpenAPI importer shown above. `invoke` honors the same\napproval gate. `--approve-when` picks the policy (`THINGCTX_APPROVE_WHEN`\nis the fallback), `-y` approves without a prompt, and with no terminal and\nno `-y` a gated call is denied with a non zero exit. An agent shelling out\ngets the same protection as one holding the library.\n\n## Extend it\n\nDescribing a new system needs nothing from this repo. Teaching thingctx\nsomething it does not know yet is one class, and every part works the same\nway, so what you learn once applies to the rest.\n\nA transport is the common case:\n\n```python\nclass CoapBinding:\n    scheme = \"coap\"\n\n    async def invoke(self, action, form, arguments): ...  # required\n    async def read(self, prop, form): ...  # optional\n```\n\nThe contract is a `scheme` plus an async `invoke`. Add `read`, `write`, or\n`subscribe` if the transport supports them; the runtime checks which methods\nexist before calling, so a pub sub transport without reads simply has none.\nPass the binding to the client and it is live. Registering one for a scheme\nthingctx already serves replaces the built in binding.\n\nThe same shape covers the rest:\n\n| To teach thingctx | Implement | Prove it with |\n|---|---|---|\n| a new transport | `ProtocolBinding` | `assert_binding_contract` |\n| a new way to authenticate | `CredentialProvider` | `assert_provider_contract` |\n| a new place descriptions come from | `Registry` | `assert_registry_contract` |\n| a new media engine | `MediaBackend` | `assert_media_backend_contract` |\n| a new protocol to serve a fleet on | `GatewayBinding` | `assert_gateway_binding_contract` |\n\nEach contract is a `typing.Protocol`: match the methods, inherit nothing.\n`@implements(Contract)` checks the match at import time, and the conformance\nkit in `thingctx.testing` checks the runtime behaviour a type checker cannot.\n[`examples/13_custom_stack.py`](examples/13_custom_stack.py) builds one of\neach in a single file, offline.\n\nKeep what you write private in your own package, or contribute it so nobody\nwrites it twice. Out of tree packages register through entry points and are\ndiscovered without a fork. See [`docs/BINDINGS.md`](docs/BINDINGS.md).\n\n## Interoperability\n\nthingctx consumes a description no matter who produced it. Demos under\n[`examples/interop/`](examples/interop/) prove it end to end: Eclipse\nThingweb's [node-wot](examples/interop/nodewot/) serves a Thing and\nthingctx drives it; Eclipse\n[Ditto](examples/interop/ditto/) generates a description for a digital twin\nand thingctx round trips state through it. The Thing Description is a W3C\nRecommendation, so a file you write here is portable to any other WoT\nconsumer, and thingctx reads a W3C Thing Description Directory.\n\n## Contributing\n\nDriving your own system needs nothing from this repo: write a description\nand point thingctx at it. What the project does need is transports it cannot\nspeak yet, and each one is a single class.\n\nGood places to start:\n\n- [good first issue](https://github.com/thingctx/thingctx/labels/good%20first%20issue)\n  is scoped work with the context written down. Comment to claim one and it\n  gets assigned to you.\n- [help wanted](https://github.com/thingctx/thingctx/labels/help%20wanted) is\n  everything else that is open.\n- [CONTRIBUTING.md](CONTRIBUTING.md) explains how a binding fits together.\n\nContributor patches have added a lint rule and closed a coverage gap. Small,\nscoped, merged.\n\nEvery commit needs a DCO `Signed-off-by` line, which `git commit -s` adds. AI\nassisted patches are welcome; a real human reviews and signs off.\n\nQuestions do not need an issue.\n[Discussions](https://github.com/thingctx/thingctx/discussions) is the place\nfor \"does this already do X\", \"would you take a PR for Y\", and how to reach a\nsystem you have. Asking there is useful even when the answer is no, because\nit tells the project what people are trying to do.\n\n## License\n\nApache 2.0. See [LICENSE](LICENSE).\n",
  "bytes": 17624,
  "sha": "c3649cf19a3e583c7af2244e1fe3508be51c1ec1babc2ddad329bcd5e21ce1b7",
  "repo_slug": "thingctx/thingctx",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_com_thingctx_thingctx_9d4a0fa1/readme"
}