MCP Development Guide
Version française : ../../fr/guides/mcp-development.md
This guide takes you from an empty folder to an MCP (Model Context Protocol) server that agents of a project call
through the MCP gateway of the Control Plane: template, tools, manifest, policy, tests, registration, discovery,
call, versions and publication. It is written for developers; registering a server needs a write role (for
example developer or tenant_admin), approving its tools needs tenant_admin or security_officer. Count one
hour for a first server.
Where the server runs is described in Hosting MCP servers, the per user instances in MCP Runtime per user and project and the controls of the gateway in MCP security.
Before you begin
- The
enterprise-aiCLI of the Python SDK is installed and logged in. Check:enterprise-ai doctor. - OPA is installed for the policy tests. Check:
opa version. - You know the project (id or slug) the server belongs to, for example
alpha. - The server will run at an internal address only. The gateway never reaches an online MCP server: the host of
the URL must be named in
EAI_MCP_INTERNAL_HOSTS(exact host, host:port,*.suffixor CIDR) by the platform operator, unless the server is hosted by the Navin Manager or runs in the MCP Runtime. - Secrets are references only (
openbao://...), never values in the code, the manifests or the requests.
Create a server from the template
The template sdks/python/src/enterprise_ai/templates/mcp-server gives an MCP server that answers JSON-RPC 2.0
(initialize, tools/list, tools/call), its Tool manifest, its OPA policy and its tests. secure-mcp is an
alias of mcp-server.
-
Create the project. The command copies the template and replaces its name everywhere.
enterprise-ai create mcp files-server --template secure-mcpcd files-serverExpected result: a
files-serverfolder withserver.py,tool.yaml,policies/,tests/,README.md,README.fr.md,AGENTS.mdandRULES.md.If it fails: an existing folder is never overwritten; choose another name or add
--force. Runenterprise-ai doctorwhen the CLI itself does not start. -
Read the rules of the template.
AGENTS.mdandRULES.mdlist what a change must keep: no secret, no direct model call, tests for every change, English and French texts, least privilege.Expected result: you know the checks that
enterprise-ai validate,enterprise-ai test authzand the unit tests run.If it fails: the files are missing when the folder was created by hand; create it again with step 1.
What the template contains
| File | Role |
|---|---|
server.py | JSON-RPC 2.0 handler, transport agnostic: initialize (protocol version, serverInfo), tools/list, tools/call, errors -32600, -32601, -32602 |
tool.yaml | Tool manifest (kind: Tool, contracts v1): schemas, endpoint.type: mcp, endpoint.mcp_server, scopes, classification, risk |
policies/mcp.rego, policies/mcp_test.rego | Deny by default policy and its tests |
tests/test_server.py, tests/test_manifests.py | Unit tests of the handler and of the manifests |
template.yaml | Description of the template: manifests, policies, test kinds (pytest, opa) |
Write tools, manifest and policies
-
Declare each tool in
server.py. A tool has aname, a bilingualdescriptionand aninputSchemathat refuses unknown properties. The gateway hashes the description, the input schema and the output schema of each tool: these three fields define its version in the catalogue.TOOLS = [{"name": "lookup","description": "Look up a product by reference / Rechercher un produit par référence","inputSchema": {"type": "object", "required": ["reference"],"properties": {"reference": {"type": "string", "pattern": "^[A-Z0-9-]{3,32}$"}},"additionalProperties": False},}]Expected result: tools/list returns the tool, tools/call checks its arguments and answers
isError: truewith a readable message when they are invalid.If it fails: an unknown method must answer -32601 and an unknown tool -32602, as in the template.
-
Describe the tool in
tool.yaml. The manifest is what the Tool Gateway registers: same schemas as the server,endpoint.mcp_serverset to the name the server will have in the registry, the scopes the caller needs (scopes_required),data_classification,side_effects,requires_approvalandrisk_level.Expected result: the manifest describes the same tool as
server.py, with the lowest classification and the smallest set of scopes the tool needs.If it fails: a field that the contract does not define is refused by
enterprise-ai validate; remove it. -
Write the policy. The policy refuses by default. The gateway evaluates the action
mcp.callon the resourcemcp_serverwith the attributesmethodandtarget(the tool name for tools/call); adapt the rules ofpolicies/mcp.regoto that input and cover each rule with an allowed and a refused case inpolicies/mcp_test.rego. Publishing a policy to the platform is described in Policy development.Expected result: every rule has a test that allows it and a test that refuses it.
If it fails:
opa testnames the failing case; fix the rule or the case, never delete the test.
Test and validate
-
Run the unit tests. They call the handler with every method and every error.
python -m pytest -q testsExpected result: every test passes.
If it fails: read the assertion; a protocol error code that changed breaks the clients.
-
Run the policy tests.
enterprise-ai test authzruns the OPA tests ofpolicies(and the OpenFGA model tests when there are any).enterprise-ai test authzopa test policies -vExpected result:
PASSfor every case.If it fails: check the package name and the input fields used by the rules.
-
Validate the manifest. The CLI checks the manifest against the v1 contracts.
enterprise-ai validate -f tool.yamlExpected result: the document is valid, exit code 0.
If it fails: exit code 1 lists each invalid field with its path in the document.
Register the server
-
Run the server at an internal address. Expose the handler over the streamable HTTP transport (or SSE) at an address of a host named in
EAI_MCP_INTERNAL_HOSTS, or let the platform host it (Hosting MCP servers).Expected result: the server answers
initializeat its URL from the network of the Control Plane.If it fails: the gateway refuses a public host with 403
mcp_online_forbidden, at registration and at every call; ask the operator to add the internal host. -
Write the registration body.
nameis unique in the tenant (lower case, digits,.,_,-);transportisstreamable_http(default) orsse;auth.typeisnone,bearer,api_key,query_paramorconnector, with anopenbao://orenv://credential_reffor the three secret types;allowed_toolsandallowed_resourcesare glob patterns (default["*"]);projectattaches the server to one project.{"name": "files-server","url": "https://<mcp-files-host>/mcp","transport": "streamable_http","auth": {"type": "bearer", "credential_ref": "openbao://mcp/files-server/token"},"allowed_tools": ["lookup"],"allowed_resources": [],"project": "alpha"}Expected result: a body that names no secret value.
If it fails: 400
invalid_requestnames the field (auth.credential_ref,auth.param,auth.connector). -
Register the server. POST /v1/mcp/servers records it, writes its owner in OpenFGA and audits
mcp.server.create. In the portal, the same body is typed in MCP studio (pagestudio/mcp), which checks it against the documented fields, then Register.curl -sS -X POST <control-plane-url>/v1/mcp/servers \-H "Authorization: Bearer <token>" -H "X-Project: alpha" \-H "Content-Type: application/json" -d @files-server.mcp-server.jsonserver = client.mcp.register_server("files-server", "https://<mcp-files-host>/mcp",auth={"type": "bearer", "credential_ref": "openbao://mcp/files-server/token"},allowed_tools=["lookup"], project="alpha")<token>: an API key or an access token of a user with a write role.Expected result: 201 with the server, its
statusand itsauthwithout any secret.If it fails: 409
conflictwhen the name exists, 403mcp_project_forbiddenwhen the request project differs fromproject, 403mcp_online_forbiddenfor a public host. -
Restrict who may call it (optional). PUT /v1/mcp/servers/{server_id}/access lists the users and agents allowed to call the server; an empty list lets every member of its project call it. In the portal: Navin library > MCP, menu of the server.
Expected result: the answer shows
allowed_principals; any other caller getsmcp_principal_not_allowed.If it fails: 409
mcp_instance_access_managedfor the entry of an MCP Runtime instance, whose access list is its owner and its agents.
Discover and approve the tools
-
Run the discovery. POST /v1/mcp/servers/{server_id}/discover calls
initializethen tools/list through the gateway. Each new tool, and each tool whose description or schemas changed, becomes a new catalogue version with an approval request (actionmcp_tool_version). In MCP studio: Discover tools.client.mcp.discover("files-server")Expected result:
new_versionslists each tool with itsversionand itsapproval_id;unchangedlists the tools already known;server_versionis theserverInfo.versionof the server.If it fails: 502
upstream_errorwhen the server does not answerinitializeor tools/list. -
Approve the versions. A
tenant_adminor asecurity_officerreads the request with GET /v1/approvals and decides with POST /v1/approvals/{approval_id}/decide ({"decision": "approve"}).Expected result: GET /v1/mcp/catalog shows the version with
status: approved(elsepending_approvalorrejected), with its input and output schemas and its scopes.If it fails: only
tenant_adminandsecurity_officermay decide anmcp_tool_versionapproval. -
Read the tools. GET /v1/mcp/servers/{server_id}/tools lists the tools the server exposes, filtered by
allowed_tools.Expected result: only the allowed tools appear.
If it fails: 403
mcp_server_project_mismatchfrom another project; send theX-Projectheader of the project of the server.
The catalogue records the review of each schema. The gateway itself enforces allowed_tools: keep a tool out of
allowed_tools until its version is approved.
Call through the gateway
Agents and users never call an MCP server directly: POST /v1/mcp/gateway/{server_id} relays JSON-RPC 2.0 (one message or a batch of at most 50) after the checks of MCP security.
-
Open a session and call a tool. The SDK session sends
initialize, then each method with its own id.session = client.mcp.session("files-server")session.initialize()tools = session.list_tools()result = session.call_tool("lookup", {"reference": "KB-100"})Expected result:
resultholdsstructuredContentandcontent; sensitive values found by the DLP are masked.If it fails: the SDK raises
McpErrorwith the JSON-RPC code; see the troubleshooting table. -
Send a raw request (optional). The portal page MCP servers (
capabilities/mcp) has an MCP gateway tester that sends a JSON-RPC body; the SDK does the same:client.mcp.rpc("files-server", "tools/call", {"name": "lookup", "arguments": {"reference": "KB-100"}})Expected result: a JSON-RPC answer with
resultorerror.If it fails: -32601 means the method is not one of
initialize, tools/list, tools/call,resources/list,resources/read,prompts/list,ping. -
Call through the Tool Gateway (optional). Register the manifest with
enterprise-ai apply -f tool.yaml, then call POST /v1/tools/{name}/invoke: the Tool Gateway checks scopes, policy, approval and cost, then calls the MCP gateway.Expected result: the invocation appears in GET /v1/tools/invocations.
If it fails: 422
invalid_manifestwhenendpoint.mcp_serveris missing.
Version a server
-
Raise the versions. Raise
serverInfo.versioninserver.pyandmetadata.versionintool.yaml.Expected result: the new build announces its version in
initialize.If it fails: a version that does not change hides the change in the catalogue history.
-
Deploy the new build at the same URL, then discover again. Each tool whose description or schemas changed gets version N+1 and a new approval request; the others stay
unchanged.Expected result: GET /v1/mcp/catalog shows both versions of a changed tool.
If it fails: no new version means the three hashed fields did not change.
-
Roll back if needed. Deploy the previous build: its schemas match a version already recorded, so the discovery lists the tools as
unchangedand asks for no new approval.Expected result: the catalogue keeps its history; nothing is deleted.
If it fails: disable the server at once with POST /v1/mcp/servers/{server_id}/status (
{"status": "disabled", "reason": "..."}); enabling it again (active) goes through a promotion request (202).
The registry has no route to change the URL or allowed_tools of a registered server. To change them, a
tenant_admin deletes the server (DELETE /v1/mcp/servers/{server_id}) and registers it again; the tools of the
new server go through discovery and approval again.
Publish
To the marketplace
-
Put the server in a pack. A pack holds
mcp/servers(a YAML withnameandtools),mcp/tools(Tool manifests) andmcp/schemas. See Pack development.enterprise-ai plugin validate <pack-folder>enterprise-ai plugin build <pack-folder> --out <dist-folder>Expected result: the pack is valid and built.
If it fails: "an MCP server needs name and tools" names the file of
mcp/serversto fix. -
Sign and publish the pack. Follow Marketplace: signature, SBOM, provenance and the other gates are checked at publication.
Expected result: the package is listed by GET /v1/marketplace/packages.
If it fails: the answer names the gate that failed.
-
Give the endpoint at installation. The installer registers the MCP server in the gateway when
config.mcp_servers.<name>.urlis given (an internal address), otherwise the object stayspending_endpoint; the smoke test runsinitializethen tools/list.Expected result: the installation is
installedand its MCP server is active.If it fails: a failed smoke test rolls the change back; the installation keeps its former state.
To the company MCP catalogue
-
Build a container image of the server. Push it to an internal registry of
EAI_MCP_RUNTIME_IMAGE_REGISTRIESand note its digest.Expected result: an image reference of the form
<registry>/<path>:<tag>@sha256:<digest>.If it fails: an image without digest or from another registry is refused by the catalogue.
-
Add a template and publish the catalogue. The platform team adds the template to the templates file (start from
catalog/mcp-runtime/templates.yaml) and publishes it to SeaweedFS (EAI_MCP_CATALOG_STORAGE_PROFILE,EAI_MCP_CATALOG_KEY):cd services/control-plane.venv/bin/python -m eai.domains.mcp_runtime.catalog_cli validate templates.yaml.venv/bin/python -m eai.domains.mcp_runtime.catalog_cli publish templates.yamlExpected result:
published <profile>/<key> version <n>; users of the projects whose policy allows the template start it from Navin library > MCP instances.If it fails: exit code 1 for an invalid file, 2 when the publication is refused (storage, configuration).
To the Navin library
The MCP presets of the Navin library come from the Navin package, copied in catalog/navin/mcp-presets.json by
python tools/navin_library/sync.py. A preset is added in the Navin package, then the copy is synchronised; a
project installs it with POST /v1/projects/{project_id}/navin-library/mcp-presets/{name}/install. See
Navin library.
-
Check the copy after a synchronisation.
python tools/navin_library/sync.py --checkExpected result: exit code 0, the copy matches its source and
catalog/navin/index.jsonholds the digest of every item.If it fails: exit code 1 reports a copy that no longer matches its source; run the synchronisation again.
Verify
- GET /v1/mcp/servers lists
files-serverwithstatus: active. - GET /v1/mcp/catalog shows each tool with
status: approved. - A tools/call through the gateway answers a
result, and GET /v1/audit/events holds themcp.tools.callevent with the tool and the DLP findings.
Next steps
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
403 mcp_online_forbidden at registration or call | Host of the URL is public or not named as internal | Host the server internally; the operator adds the host to EAI_MCP_INTERNAL_HOSTS |
409 conflict at registration | Name already used in the tenant | Choose another name or delete the old server |
404 mcp_server_not_found | Wrong id or name | List the servers with GET /v1/mcp/servers |
403 mcp_server_project_mismatch or JSON-RPC -32003 "MCP server of another project" | Server of another project, or no project context | Send X-Project with the project of the server |
JSON-RPC -32003 mcp_principal_not_allowed | Caller not in the access list | Add the caller with PUT /v1/mcp/servers/{server_id}/access |
| JSON-RPC -32003 "MCP server disabled" | Server disabled | Request active with POST /v1/mcp/servers/{server_id}/status and approve the promotion |
| JSON-RPC -32003 "tool ... is not allowed on this server" | Tool outside allowed_tools | Register the server again with the tool in allowed_tools |
| JSON-RPC -32003 "scope tools:invoke required" | Token without the scope | Use a key or token with tools:invoke |
JSON-RPC -32003 dlp_blocked | A tenant DLP rule blocks the arguments or the result | Remove the sensitive data or review the rule with the security officer |
429 quota_exceeded | Tool call quota of the tenant reached | See the quotas with enterprise-ai quotas usage |
502 upstream_error or JSON-RPC -32004 | Server unreachable, HTTP error or no answer | Check the server logs and its URL |
new_versions empty after a change | Description and schemas unchanged | Only these three fields create a version |