Skip to main content

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-ai CLI 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, *.suffix or 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.

  1. Create the project. The command copies the template and replaces its name everywhere.

    enterprise-ai create mcp files-server --template secure-mcp
    cd files-server

    Expected result: a files-server folder with server.py, tool.yaml, policies/, tests/, README.md, README.fr.md, AGENTS.md and RULES.md.

    If it fails: an existing folder is never overwritten; choose another name or add --force. Run enterprise-ai doctor when the CLI itself does not start.

  2. Read the rules of the template. AGENTS.md and RULES.md list 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 authz and 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​

FileRole
server.pyJSON-RPC 2.0 handler, transport agnostic: initialize (protocol version, serverInfo), tools/list, tools/call, errors -32600, -32601, -32602
tool.yamlTool manifest (kind: Tool, contracts v1): schemas, endpoint.type: mcp, endpoint.mcp_server, scopes, classification, risk
policies/mcp.rego, policies/mcp_test.regoDeny by default policy and its tests
tests/test_server.py, tests/test_manifests.pyUnit tests of the handler and of the manifests
template.yamlDescription of the template: manifests, policies, test kinds (pytest, opa)

Write tools, manifest and policies​

  1. Declare each tool in server.py. A tool has a name, a bilingual description and an inputSchema that 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: true with 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.

  2. Describe the tool in tool.yaml. The manifest is what the Tool Gateway registers: same schemas as the server, endpoint.mcp_server set to the name the server will have in the registry, the scopes the caller needs (scopes_required), data_classification, side_effects, requires_approval and risk_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.

  3. Write the policy. The policy refuses by default. The gateway evaluates the action mcp.call on the resource mcp_server with the attributes method and target (the tool name for tools/call); adapt the rules of policies/mcp.rego to that input and cover each rule with an allowed and a refused case in policies/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 test names the failing case; fix the rule or the case, never delete the test.

Test and validate​

  1. Run the unit tests. They call the handler with every method and every error.

    python -m pytest -q tests

    Expected result: every test passes.

    If it fails: read the assertion; a protocol error code that changed breaks the clients.

  2. Run the policy tests. enterprise-ai test authz runs the OPA tests of policies (and the OpenFGA model tests when there are any).

    enterprise-ai test authz
    opa test policies -v

    Expected result: PASS for every case.

    If it fails: check the package name and the input fields used by the rules.

  3. Validate the manifest. The CLI checks the manifest against the v1 contracts.

    enterprise-ai validate -f tool.yaml

    Expected 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​

  1. 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 initialize at 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.

  2. Write the registration body. name is unique in the tenant (lower case, digits, ., _, -); transport is streamable_http (default) or sse; auth.type is none, bearer, api_key, query_param or connector, with an openbao:// or env:// credential_ref for the three secret types; allowed_tools and allowed_resources are glob patterns (default ["*"]); project attaches 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_request names the field (auth.credential_ref, auth.param, auth.connector).

  3. 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 (page studio/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.json
    server = 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 status and its auth without any secret.

    If it fails: 409 conflict when the name exists, 403 mcp_project_forbidden when the request project differs from project, 403 mcp_online_forbidden for a public host.

  4. 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 gets mcp_principal_not_allowed.

    If it fails: 409 mcp_instance_access_managed for the entry of an MCP Runtime instance, whose access list is its owner and its agents.

Discover and approve the tools​

  1. Run the discovery. POST /v1/mcp/servers/{server_id}/discover calls initialize then 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 (action mcp_tool_version). In MCP studio: Discover tools.

    client.mcp.discover("files-server")

    Expected result: new_versions lists each tool with its version and its approval_id; unchanged lists the tools already known; server_version is the serverInfo.version of the server.

    If it fails: 502 upstream_error when the server does not answer initialize or tools/list.

  2. Approve the versions. A tenant_admin or a security_officer reads 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 (else pending_approval or rejected), with its input and output schemas and its scopes.

    If it fails: only tenant_admin and security_officer may decide an mcp_tool_version approval.

  3. 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_mismatch from another project; send the X-Project header of the project of the server.

note

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.

  1. 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: result holds structuredContent and content; sensitive values found by the DLP are masked.

    If it fails: the SDK raises McpError with the JSON-RPC code; see the troubleshooting table.

  2. 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 result or error.

    If it fails: -32601 means the method is not one of initialize, tools/list, tools/call, resources/list, resources/read, prompts/list, ping.

  3. 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_manifest when endpoint.mcp_server is missing.

Version a server​

  1. Raise the versions. Raise serverInfo.version in server.py and metadata.version in tool.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.

  2. 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.

  3. Roll back if needed. Deploy the previous build: its schemas match a version already recorded, so the discovery lists the tools as unchanged and 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).

warning

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​

  1. Put the server in a pack. A pack holds mcp/servers (a YAML with name and tools), mcp/tools (Tool manifests) and mcp/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/servers to fix.

  2. 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.

  3. Give the endpoint at installation. The installer registers the MCP server in the gateway when config.mcp_servers.<name>.url is given (an internal address), otherwise the object stays pending_endpoint; the smoke test runs initialize then tools/list.

    Expected result: the installation is installed and 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​

  1. Build a container image of the server. Push it to an internal registry of EAI_MCP_RUNTIME_IMAGE_REGISTRIES and 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.

  2. 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.yaml

    Expected 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.

  1. Check the copy after a synchronisation.

    python tools/navin_library/sync.py --check

    Expected result: exit code 0, the copy matches its source and catalog/navin/index.json holds 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-server with status: 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 the mcp.tools.call event with the tool and the DLP findings.

Next steps​

Troubleshooting​

SymptomCauseFix
403 mcp_online_forbidden at registration or callHost of the URL is public or not named as internalHost the server internally; the operator adds the host to EAI_MCP_INTERNAL_HOSTS
409 conflict at registrationName already used in the tenantChoose another name or delete the old server
404 mcp_server_not_foundWrong id or nameList 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 contextSend X-Project with the project of the server
JSON-RPC -32003 mcp_principal_not_allowedCaller not in the access listAdd the caller with PUT /v1/mcp/servers/{server_id}/access
JSON-RPC -32003 "MCP server disabled"Server disabledRequest 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_toolsRegister the server again with the tool in allowed_tools
JSON-RPC -32003 "scope tools:invoke required"Token without the scopeUse a key or token with tools:invoke
JSON-RPC -32003 dlp_blockedA tenant DLP rule blocks the arguments or the resultRemove the sensitive data or review the rule with the security officer
429 quota_exceededTool call quota of the tenant reachedSee the quotas with enterprise-ai quotas usage
502 upstream_error or JSON-RPC -32004Server unreachable, HTTP error or no answerCheck the server logs and its URL
new_versions empty after a changeDescription and schemas unchangedOnly these three fields create a version