Governed MCP gateway for adding policy enforcement, tenant binding, receipts, and durable memory hooks to AI tool execution with low client adoption friction.
Use it in one of two ways:
- Official MCP over Streamable HTTP at
POST /mcp - Official MCP over stdio with
--stdio
This service fronts the Keon Runtime Gateway and exposes a governed tool surface that:
- validates JWTs or gateway API keys
- binds
tenant_idandactor_idfail-closed - enforces per-tool scopes
- requires
Decidebefore anyExecute - validates legacy request and response envelopes against
contracts/mcp_gateway.v1.schema.json - emits durable ingress spine receipts for
directive,intent, and terminaloutcome - preserves the canonical governed envelope in MCP
structuredContent
This is not only a custom HTTP adapter anymore. It now exposes standard MCP methods so existing MCP-capable clients can connect directly.
| Use case | Transport | Path / Mode | Status |
|---|---|---|---|
| Remote MCP client | Streamable HTTP | POST /mcp |
Supported |
| Local MCP client | stdio | --stdio |
Supported |
SSE stream from GET /mcp |
Streamable HTTP optional feature | GET /mcp |
Not emitted yet |
initializenotifications/initializedpingtools/listtools/call
2025-11-252025-06-182025-03-26
For browser-based HTTP clients, non-loopback origins are blocked by default. Add explicit origins to McpServer:AllowedOrigins when you want browser access outside localhost.
keon.governed.execute.v1keon.launch.hardening.v1
Over MCP:
tools/listreturns standard MCP tool descriptorstools/callreturns summary text incontent- the full governed Keon envelope is preserved in
structuredContent - failures return
isError=true
Over the legacy HTTP surface:
- request and response bodies use the canonical Keon schema envelope
- .NET 10 SDK
- Python if you want to use the included JWT demo helper
dotnet restore tests\Keon.McpGateway.Tests\Keon.McpGateway.Tests.csproj
dotnet test tests\Keon.McpGateway.Tests\Keon.McpGateway.Tests.csproj
dotnet build src\Keon.McpGateway\Keon.McpGateway.csprojhttp://localhost:5000
This path uses a bearer token. For JWT mode, tenant_id and actor_id can be derived from claims, so you do not need extra MCP headers.
Install the Python dependencies once:
pip install pyjwt cryptographyMint a token and keypair:
$auth = python .\examples\demo_jwt.py `
--tenant-id tnt_123 `
--actor-id usr_456 `
--scopes keon:mcp:list keon:mcp:invoke keon:execute `
--private-key "$env:TEMP\keon-mcp-private.pem" `
--public-key "$env:TEMP\keon-mcp-public.pem" | ConvertFrom-Json
$env:Auth__JwtPublicKeyPem = Get-Content -Raw $auth.public_key
$env:KEON_MCP_BEARER_TOKEN = $auth.tokendotnet run --project src\Keon.McpGateway\Keon.McpGateway.csproj$headers = @{
Authorization = "Bearer $env:KEON_MCP_BEARER_TOKEN"
}
Invoke-RestMethod `
-Uri "http://localhost:5000/mcp" `
-Method Post `
-Headers $headers `
-ContentType "application/json" `
-Body '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"manual-smoke","version":"1.0.0"},"capabilities":{}}}'$headers["MCP-Protocol-Version"] = "2025-06-18"
Invoke-RestMethod `
-Uri "http://localhost:5000/mcp" `
-Method Post `
-Headers $headers `
-ContentType "application/json" `
-Body '{"jsonrpc":"2.0","id":"tools-list","method":"tools/list","params":{}}'Invoke-RestMethod `
-Uri "http://localhost:5000/mcp" `
-Method Post `
-Headers $headers `
-ContentType "application/json" `
-Body '{
"jsonrpc":"2.0",
"id":"call-1",
"method":"tools/call",
"params":{
"name":"keon.governed.execute.v1",
"arguments":{
"purpose":"Summarize recent sent emails for weekly status update",
"action":"summarize",
"resource":{"type":"email","scope":"mailbox:sent"},
"params":{"window_days":7,"max_items":25},
"mode":"decide_then_execute"
}
}
}'The MCP result will contain:
- human-readable summary text in
result.content - the canonical governed envelope in
result.structuredContent decision,receipts, andresultpayloads suitable for memory and audit handling
Use this when your client supports remote MCP servers over HTTP.
{
"type": "streamable-http",
"url": "http://localhost:5000/mcp",
"headers": {
"Authorization": "Bearer <token>",
"MCP-Protocol-Version": "2025-06-18"
}
}Notes:
- For JWT mode,
tenant_idandactor_idcan be derived from token claims. - For API key mode, include
X-Api-Key,X-Keon-Tenant-Id, andX-Keon-Actor-Id. - For browser clients, configure
McpServer:AllowedOrigins.
Use this when your client prefers launching a local MCP server process.
Build first:
dotnet build src\Keon.McpGateway\Keon.McpGateway.csprojThen point the client at the built DLL:
{
"type": "stdio",
"command": "dotnet",
"args": [
"D:\\Repos\\keon-omega\\keon-mcp-gateway\\src\\Keon.McpGateway\\bin\\Debug\\net10.0\\Keon.McpGateway.dll",
"--stdio"
],
"env": {
"KEON_MCP_BEARER_TOKEN": "<token>"
}
}Optional stdio environment variables:
KEON_MCP_BEARER_TOKENKEON_MCP_API_KEYKEON_MCP_TENANT_IDKEON_MCP_ACTOR_ID
Notes:
- In bearer-token mode,
tenant_idandactor_idcan be derived from token claims. - In API-key mode, set
KEON_MCP_TENANT_IDandKEON_MCP_ACTOR_ID. - Prefer the built DLL or
dotnet run --no-build ... -- --stdioso stdout stays clean for the MCP stream.
Use bearer auth when you want tenant and actor identity bound to signed claims.
The gateway enforces:
- issuer and audience validation
- signature validation via
Auth:JwtPublicKeyPemorAuth:JwksUrl - required gateway scopes plus per-tool scopes
- fail-closed tenant and actor binding
Typical scopes:
keon:mcp:listkeon:mcp:invokekeon:executekeon:attest
Use API key auth when you want low-friction service-to-service adoption with gateway-side entitlement checks.
The gateway enforces:
- key format and secret validation
- active API key and environment state
- tenant entitlement state
- quota checks for governed execution
- durable usage-event outbox writes on successful invokes
For HTTP MCP clients, send:
X-Api-KeyX-Keon-Tenant-IdX-Keon-Actor-Id
For stdio MCP clients, set:
KEON_MCP_API_KEYKEON_MCP_TENANT_IDKEON_MCP_ACTOR_ID
Every governed invoke follows the same core path:
- Accept request and bind tenant and actor
- Emit
directive - Emit
intent - Ask runtime to
Decide - If approved and requested, call
Execute - Emit terminal
outcome
Receipt keys are stable in the canonical envelope. Values are nullable when the lifecycle stage did not occur or a reference is unavailable:
directiveintentrequestwhen execute is attempted and request emission occurreddecisionexecutionwhen execute succeedsoutcomeonce terminal outcome emission succeedsevidence_packwhen available
This is the basis for governance and memory:
- the agent gets a standard MCP result
- the application gets structured receipts and terminal status
- the platform gets durable memory hooks and audit material
tools/call returns a standard MCP result. The governed Keon envelope is preserved inside structuredContent.
{
"jsonrpc": "2.0",
"id": "call-1",
"result": {
"content": [
{
"type": "text",
"text": "keon.governed.execute.v1 completed with decision approved. Receipts are available in structuredContent."
}
],
"structuredContent": {
"correlation_id": "c01J9Z8Q6X4J5Y2P9H3K8M7N6",
"tool": "keon.governed.execute.v1",
"ok": true,
"decision": {
"status": "approved",
"policy_hash": "sha256:9c1af02e"
},
"result": {
"summary": "done"
},
"receipts": {
"directive": "rcpt_dir_01J9Z9",
"intent": "rcpt_int_01J9Z9",
"request": "rcpt_req_01J9Z9",
"decision": "rcpt_dec_01J9Z9",
"execution": "rcpt_exe_01J9Z9",
"outcome": "rcpt_out_01J9Z9",
"evidence_pack": null
}
},
"isError": false
}
}The original HTTP endpoints are still available for systems that already integrated the canonical Keon envelope directly.
Request schema: ToolsListRequest
Response schema: ToolsListResponse
Request schema: ToolsInvokeRequest
Response schema: ToolsInvokeResponse
See contracts/README.md and contracts/mcp_gateway.v1.schema.json.
src/Keon.McpGateway/appsettings.json
{
"Runtime": {
"BaseUrl": "http://localhost:8080",
"TimeoutSeconds": 5,
"MaxRetries": 2
},
"ControlPlane": {
"BaseUrl": "http://localhost:5000",
"TimeoutSeconds": 3
},
"IngressSpine": {
"Mode": "Off",
"ConnectionString": "Data Source=ingress-spine.db"
},
"RateLimiting": {
"Enabled": false,
"PermitLimit": 20,
"WindowSeconds": 60
},
"McpServer": {
"ServerName": "Keon MCP Gateway",
"SupportedProtocolVersions": [ "2025-11-25", "2025-06-18", "2025-03-26" ],
"AllowedOrigins": [],
"DefaultTenantId": "",
"DefaultActorId": "",
"DefaultBearerToken": "",
"DefaultApiKey": ""
},
"Auth": {
"Issuer": "keon-auth",
"Audience": "keon-mcp-gateway",
"JwksUrl": "",
"JwtPublicKeyPem": "",
"RequiredScopes": []
}
}IngressSpine:Mode
Off: no ingress persistenceBestEffort: append failures are logged and never block request completionRequired: append failures are fail-closed; ifdirectiveappend fails the runtime is never called
McpServer:AllowedOrigins
- empty means only non-browser and loopback browser origins are accepted
- add explicit origins for browser-based remote clients
McpServer:DefaultTenantId and McpServer:DefaultActorId
- useful for stdio mode when the calling client cannot inject custom MCP headers
- not required for bearer tokens that already carry
tenant_idandactor_id
McpServer:DefaultBearerToken and McpServer:DefaultApiKey
- intended for local development or tightly controlled launcher environments
- prefer environment variables over checked-in config for secrets
$env:Runtime__BaseUrl = "https://api.keon.systems"
dotnet run --project src\Keon.McpGateway\Keon.McpGateway.csproj$env:Runtime__BaseUrl = "https://keon-runtime.internal"
dotnet run --project src\Keon.McpGateway\Keon.McpGateway.csprojBuild:
docker build -t keon-mcp-gateway .Run:
docker run --rm -p 8080:8080 `
-e Runtime__BaseUrl=http://host.docker.internal:8080 `
-e Auth__JwtPublicKeyPem="<public-key-pem>" `
keon-mcp-gatewayContainer port:
http://localhost:8080
GET /healthverifies the downstream runtime status and returns both gateway and runtime state- rate limiting is applied to the public MCP routes when
RateLimiting:Enabled=true /mcpcurrently supports request/response Streamable HTTP; SSE streaming fromGET /mcpis not emitted yet- stdio mode is intended for local MCP client launches and should be run without extra stdout noise
Included examples:
examples/invoke_client.py: legacy HTTP invoke clientexamples/langchain_keon_tool.py: LangChain wrapperexamples/demo_jwt.py: mint local dev JWTs and keypairsexamples/mint_token.ps1: mint JWT from an existing private keyexamples/mock_runtime_server.py: demo runtime
Trust-failure demos:
.\examples\demo_trust_failure_runtime_down.ps1.\examples\demo_policy_deny_blocks_summarize.ps1
Expected outcomes:
- runtime-down path fails closed
- deny path does not execute
- ingress spine still records
directive -> intent -> outcome
Minimal tool metadata and legacy schemas live in:
contracts/mcp_gateway.v1.schema.jsonvendor/keon-contracts/Hardening/schema/hardening_attestation.v1.schema.json
The automated suite covers:
- schema fixture validation for 6 golden payloads
- approve and execute
- deny without execute
- missing scope
- tenant mismatch
- runtime unavailable
- correlation preservation through decide and execute
- required spine behavior
- best-effort spine behavior
- rate limiting
- MCP
initialize - MCP
tools/list - MCP
tools/call - MCP origin enforcement
- SSE response streaming from
GET /mcp - MCP resources, prompts, and other non-tool capabilities
- live spine persistence for Directive, Intent, Request, and Outcome receipts beyond the current SQLite-backed sink
- real
keon.launch.hardening.v1execution wiring beyond the current governed decision stub - remote git hosting and PR publication
- PR validation:
.github/workflows/pr-validation.yml - Staging deploy:
.github/workflows/deploy-staging.yml
AZURE_CLIENT_ID: Azure federated identity app client ID for GitHub OIDC loginAZURE_TENANT_ID: Azure tenant ID for OIDC loginAZURE_SUBSCRIPTION_ID: Azure subscription ID used for ACR and Container Apps operations
ACR_NAME: Azure Container Registry name without FQDNACR_LOGIN_SERVER: ACR login server, for examplemyregistry.azurecr.ioACR_IMAGE_REPOSITORY: image repo path in ACR, for examplekeon-mcp-gatewayACA_RESOURCE_GROUP: resource group containing the Container AppACA_APP_NAME: Azure Container App name for stagingSTAGING_HEALTHCHECK_URL: optional override for the health URL
Protect main and require:
Restore, Build, Test, Python Smoke
Recommended:
- require pull requests before merge
- require linear history or squash merge
- restrict direct pushes to
main - require status checks before merge
- staging deploy does not run on pull requests
- deploy runs only on protected
mainor manualworkflow_dispatchagainst protectedmain - staging environment approvals can gate deployment
- Identify the last known-good image tag.
- Authenticate to Azure with least-privilege operator credentials.
- Roll back the Container App image.
- Run smoke checks against
/health. - Capture rollback details in incident notes.