modulex-integrations package as a Python package, and the ModuleX runtime discovers it through the modulex.tools entry-point group and loads it at startup.
This page is the end-to-end author’s guide: the directory the runtime expects, the manifest it reads, the @tool function contract it executes, how you register your integration so the runtime can find it, and how to test it locally. Two companion pages go deeper on the contracts referenced throughout: the manifest and schema contract (every field, type, default, and the six auth schema variants) and the @tool function contract (decorator order, auth-parameter conventions, output models, and error patterns). If instead of authoring a package you want to plug in an external server, see custom MCP servers.
modulex-integrations package targets Python 3.12+ and is built with Hatchling + hatch-vcs (the version is derived from git tags; there is no central version file). Its three always-installed core dependencies are pydantic>=2.5.0, httpx>=0.25.0, and langchain-core>=0.3.0. Everything a specific tool needs beyond those is per-tool.Before you start
Pick a name
^[a-z][a-z0-9_]*$ — for example github, google_ads, mongodb_atlas. This one string is your directory name, your entry-point key, your manifest name, and the credential/catalog key the runtime uses. They must all be identical.Clone the repository and install the dev extras
dev extras group pulls in pytest, pytest-asyncio, pytest-httpx, ruff, and mypy, plus a hand-maintained mirror of several vendor SDKs so type-checking can import them.Copy an existing integration as your starting point
src/modulex_integrations/tools/ (for example github for a token-auth REST API, or exa for an API-key service), then edit the manifest, tools, outputs, and tests for your service. Every integration ships the identical file set, so reviews are mechanical.Anatomy of an integration
Every integration lives atsrc/modulex_integrations/tools/<name>/ and ships the same fixed set of files. The contract is enforced by pydantic types in schema.py with extra="forbid" everywhere — a typo in a manifest fails at import time, not at runtime.
manifest.py
IntegrationManifest instance named manifest: metadata, actions, and auth_schemas. The single source of truth for the integration’s identity and how it authenticates.tools.py
args_schema input models and the @tool functions, plus an auth-header helper. The @tool function names must match the manifest actions.outputs.py
@tool return annotations. The runtime derives each action’s output schema from these.__init__.py
manifest and a TOOLS tuple of the @tool objects.dependencies.toml
dependencies = [...] list of the vendor SDKs this tool needs (often empty when only httpx is used).README.md
The two symbols the runtime reads
The runtime imports your integration’s__init__.py and reads exactly two names. Nothing else in the module is part of the runtime contract.
IntegrationManifest instance (re-exported from manifest.py) describing the integration’s metadata, actions, and auth schemas. See the manifest and schema contract for every field.StructuredTool objects, one per action — the @tool-decorated functions from tools.py. Both the runtime and the docs export script read getattr(module, "TOOLS", ()), so the attribute must be a tuple named exactly TOOLS.__init__.py re-exports both symbols:
Step 1 — Write the manifest
manifest.py builds exactly one IntegrationManifest instance bound to the module-level name manifest. The model rejects unknown fields (extra="forbid"), so import will fail if you add a field that is not in the schema. There is no output_schema, docs_url, features, rate_limits, pricing, or metadata field — those belong in the README, not the manifest.
IntegrationManifest fields
tool — the only allowed value. (LLM and knowledge providers are not authored here; they stay in the modulex backend.)^[a-z][a-z0-9_]*$ and be identical to the directory name and the entry-point key (for example github).GitHub).modulex:<name>-themed (for example modulex:github-themed).["Developer Tools & Infrastructure", "version-control"]).ActionDefinition per callable action. Each action’s name must match a @tool function name in TOOLS.auth_type). An integration may expose more than one — GitHub ships OAuth2 plus a personal-access-token bearer schema. See the six auth schema variants.oauth2, bearer_token, api_key, modulex_key, custom, internal), the OAuthConfig, EnvVar, TestEndpoint, SuccessIndicators, and BasicAuthSpec fields, and the only_for_custom versus inject_into_auth_data behavior, see the manifest and schema contract.
Step 2 — Write the @tool functions
tools.py holds one async def per action, each carrying a paired pydantic args_schema input model and a typed pydantic return annotation from outputs.py. Every function uses the same mandatory two-decorator stack.
from __future__ import annotations at the top of both tools.py and outputs.py. The backend reads each @tool’s return annotation via typing.get_type_hints(...) (not raw __annotations__) precisely to resolve these stringified PEP 563 annotations into the real pydantic class.Auth parameter conventions
The runtime injects credentials at call time and strips the credential fields from the schema the model sees, so the LLM never reads tokens. There are two conventions; pick the one that matches your auth schemas.- auth_type + auth_data (token-based)
- api_key (key-based)
oauth2 and bearer_token schemas, the first two parameters are auth_type: str and auth_data: dict[str, Any]. A module-level helper builds the provider headers, branching on auth_type:Output models
outputs.py defines one typed pydantic model per action. Each model subclasses a shared base with model_config = ConfigDict(extra="forbid"). The only field guaranteed across every integration is success: bool; an error: str | None field exists only on integrations whose tools return a failure payload rather than raising.
model_json_schema() on the resolved return type at startup — which is why the return annotation must stay the pydantic class even though @serialize_pydantic_return coerces the runtime value to a dict.
Choose one error pattern
Pick the pattern that fits how your upstream service signals failure and apply it consistently across the integration.Pattern A — raise on error (GitHub style)
Pattern A — raise on error (GitHub style)
response.raise_for_status() after each request and only ever construct success=True models; HTTP errors propagate as exceptions. Output models have no error field. Use this when the upstream API returns standard HTTP error status codes.Pattern B — inline ok:false (Slack style)
Pattern B — inline ok:false (Slack style)
{"ok": false, "error": "..."}. Read the JSON, check the flag, and on failure return the output model with success=False and error set; on success leave error as None. Do not call raise_for_status() — inspect the body instead.Pattern C — try/except wrapping (Exa style)
Pattern C — try/except wrapping (Exa style)
try/except so non-2xx responses, timeouts, and unexpected exceptions all surface as success=False with error set, never raising. Pre-validate the credential and set explicit per-endpoint timeouts. Use this when you want the tool to always return a structured result.create_issue, Slack, Exa), the exact serialize_pydantic_return behavior, and how the annotation becomes the output schema, see the @tool function contract.
Step 3 — Register the entry point
The ModuleX runtime discovers integrations through the Pythonmodulex.tools entry-point group, not by scanning the filesystem. Add one line for your integration to the root pyproject.toml. The key is your integration name; the value is the <name> package path (not the .tools module — the runtime appends .tools itself when it needs the tool module).
entry_points(group="modulex.tools"), imports each module, reads its manifest and TOOLS, converts them into the legacy action-dict shape it expects, and caches the result for the process lifetime. Restart the worker to pick up a freshly installed package version.
scripts/export_manifests.py) uses a separate discovery mechanism: it walks the filesystem for tools/*/manifest.py and uses the entry-point table only as a cross-check. So a tool can exist on disk and export to the docs catalog even before its entry point is registered — but the runtime will not load it until the entry-point line is present. Always add the entry-point line.Declare runtime dependencies
List any vendor SDKs your tool imports in itsdependencies.toml. Leave it as an empty list when the tool only uses httpx (already a core dependency).
Step 4 — Write the README
Every integration’sREADME.md follows the same five-section template, in order. CI enforces the section list, and the export script splits the README into the prose that keeps each generated catalog page from being thin content.
Title + summary
Authentication
Tools
name | description | required params, plus the note that the runtime injects the auth parameters.Limits & Quotas
ok:false versus try/except).Step 5 — Test it locally
Each integration ships at least one happy-path test per@tool, plus a manifest sanity check. HTTP tools use pytest-httpx to mock responses; tools that call a vendor SDK stub the client with unittest.mock.patch. Tools are invoked exactly as the runtime invokes them — via LangChain .ainvoke({...}) with a single input dict — and they return a dict (because @serialize_pydantic_return coerced the model).
manifest.py / tools.py / outputs.py / __init__.py), (2) register the modulex.tools entry point in pyproject.toml, (3) pip install / package build, (4) runtime startup enumerates entry points and reads manifest + TOOLS, (5) the tool becomes callable from the LLM node, Agent node, Composer, and Assistant. Show the docs/CI filesystem-walk export branch as a side path feeding the catalog. 16:9, light and dark variants, ModuleX brand palette, no UI chrome.Validate the contract before you ship
Import the manifest in isolation
Import the manifest in isolation
extra="forbid", an unknown or misspelled field fails when the module is imported. Importing manifest.py (or running pytest, which imports it) surfaces these errors immediately — there is no runtime-only failure mode for schema typos.Confirm names line up four ways
Confirm names line up four ways
name, the entry-point key, and the <name> segment of the entry-point value. Each ActionDefinition.name must equal a @tool function name listed in TOOLS. The test assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} catches drift.Keep args_schema and manifest parameters consistent
Keep args_schema and manifest parameters consistent
args_schema is the authoritative list of what the tool accepts; the manifest parameters is descriptive. If a parameter reaches the upstream API, it should appear on the args_schema. Avoid documenting a parameter in the manifest that the function does not accept.Pick a license-compatible upstream and a single error pattern
Pick a license-compatible upstream and a single error pattern
auth_type, auth_data, api_key, token, access_token, and similar from the model-facing schema.Next steps
Manifest & schema contract
OAuthConfig, EnvVar, TestEndpoint, and BasicAuthSpec.@tool function contract
Custom MCP servers
Contributing
modulex-integrations and what reviewers check.