← Back to design archive

MCP Server

architecture draft Updated

Problem

To make application data and functions available to AI clients via MCP, three things are needed:

  1. A stable, public URL — AI clients connect to MCP servers via Streamable HTTP. The URL must be reachable and human-readable. Internal GUID-based paths don’t work for external consumption.

  2. API key authentication — AI clients like Claude don’t do OAuth/JWT flows. They send a static token in the request. The platform already has API key authentication for REST APIs; MCP needs the same path.

  3. Runtime tools that do something useful — the platform’s existing MCP tools are all design-mode (compile DSL, validate syntax, get artifacts). For a user-facing app, AI clients need tools that interact with the app’s data: query, create, update, execute functions.

Design

Slug-Based MCP URL

Each application gets a clean, subdomain-based MCP endpoint:

https://myapp.osyrin.dev/mcp

The platform’s subdomain routing middleware already resolves myapp.osyrin.dev to an application ID. The MCP endpoint reads the resolved ID from the request context instead of requiring it as a route parameter.

The existing GUID-based endpoint (/api/applications/{appId}/mcp) stays for internal and design-time use. The slug-based path (/mcp) is the public-facing URL for runtime tools.

Why not under /api/? The /api/ prefix routes through REST API middleware (rate limiting, versioning, response formatting). MCP has its own transport protocol (Streamable HTTP with SSE) and shouldn’t go through that pipeline.

API Key Authentication for MCP

The authentication flow reuses the platform’s existing API key infrastructure:

Client sends: POST https://myapp.osyrin.dev/mcp
Headers: X-API-Key: osy_ak_abc123...

Subdomain middleware: myapp -> AppId
MCP auth middleware:
  1. Read X-API-Key header
  2. Validate via HMAC-SHA256 hash lookup (same path as REST API)
  3. If valid: build security context with user's roles
  4. If invalid: 401 Unauthorized

The API key resolves to a real user. RBAC applies to MCP tools the same way it applies to REST API calls — the API key user’s roles determine what they can query, create, update, and delete. The app designer decides the access model: use your own key, or create a shared “department agent” user with scoped roles.

Audit trail:

Every MCP-initiated change is recorded with full provenance:

  • UserId — the user who owns the API key
  • SourceMCP (distinguishable from RestAPI, Function, Designer, Agent)
  • ClientInfo — self-reported client name and version from the MCP initialize handshake (e.g., claude-code/1.0.0, cursor/0.45.0)
  • CorrelationId — 12-character hex request correlation ID for end-to-end tracing
  • FunctionName — if the change was caused by a function execution

The ClientInfo is self-reported (not authenticated), so it’s useful for audit but not for access control. The security boundary is the API key and roles, not the client identity.

Auth configuration in the DSL:

The mcp: block uses the same auth: syntax as the api: block — supporting apiKey, bearer, and oauth: ProviderName:

mcp:
  description = "Order management API"
  auth:
    apiKey
    bearer
    oauth: Google, GitHub

  toolcatalog:
    # ...

The same AST node (ApiAuthNode) is reused — no new auth parsing needed. In development, both endpoints accept anonymous connections. In production, the design endpoint uses JWT Bearer (for the AppStudio UI) and the runtime endpoint uses whatever the auth: block declares.

The mcp: Block — Declaring What Gets Exposed

The platform’s existing design-mode MCP tools (compile, validate, get artifacts) are irrelevant for runtime use. A user app needs to declare which tools are available to external MCP clients and who can see them.

The mcp: block mirrors the agent: block’s tool syntax. Where agent: declares what tools an agent can call, mcp: declares what tools the app exposes to external callers.

Agent block (existing, for reference):

agent MyAgent:
  llm = MyClaude
  purpose = "Helps manage tasks"
  tools:
    function = CreateTask
    function = UpdateTask
    mcp = ExternalServer
    agent = SubAgent

MCP block (same tool syntax, adds visibility):

mcp:
  description = "API for managing orders and products"

  toolcatalog:
    name = "Editor Tools"
    visibleTo = "@CurrentUser.Roles CONTAINS 'Editor'"
    description = "Content editing tools"
    tools:
      function = CreateArticle
      function = UpdateArticle
      function = PublishArticle

  toolcatalog:
    name = "Admin Tools"
    visibleTo = "@CurrentUser.Roles CONTAINS 'Admin'"
    description = "Administrative tools"
    tools:
      function = ImportProducts
      function = ExportReport
      function = BulkUpdate

  toolcatalog:
    name = "Read Tools"
    visibleTo = "@CurrentUser.Roles CONTAINS 'Viewer'"
    description = "Read-only data access"
    tools:
      function = SearchProducts
      function = GetOrderStatus

How it works:

  1. When a client connects via MCP and authenticates with an API key, the server resolves the user’s roles.
  2. For each toolcatalog, it evaluates visibleTo against the user’s roles (using the same filter evaluator as security policies).
  3. The tools/list response includes only tools from catalogs the user can see.
  4. Each function = FuncName reference creates a tool definition bound to the function — the same tool definition entity used by the agent system.

Syntax alignment with the agent block:

Featureagent: blockmcp: block
Tool referencefunction = CreateTaskfunction = CreateTask (same)
MCP server toolmcp = ServerNameNot applicable (mcp: is the server, not client)
Agent delegationagent = SubAgentNot applicable
CRUD shorthandNot applicablecrud = EntityName
Groupingflat tools: listtoolcatalog: with name and visibleTo directives
Visibilityall tools available to the agentper-catalog, role-filtered

Tool descriptions and schema:

Each function already has a description, input entity, and output entity. These are used to generate the MCP tool’s description and JSON Schema automatically. To override the description for MCP presentation:

mcp:
  toolcatalog:
    name = "Editor Tools"
    visibleTo = "@CurrentUser.Roles CONTAINS 'Editor'"
    tools:
      function = CreateArticle
        description = "Create a new article with title, body, and category"

This mirrors the description/name override mechanism that already exists in the agent system.

Built-in CRUD Tools

In addition to function tools, the mcp: block can expose built-in CRUD operations for any entity:

mcp:
  toolcatalog:
    name = "Data Access"
    visibleTo = "@CurrentUser.Roles CONTAINS 'Viewer'"
    tools:
      crud:
        entity = Order
        operations = R
      crud:
        entity = Product
        operations = RU
      crud:
        entity = Customer

operations is a subset of CRUD (Create, Read, Update, Delete) — defaults to all four if omitted.

ToolOperationDescriptionKey parameters
query_{Entity}RQuery with filters, sorting, paginationfilter, orderBy, top, skip, include
get_{Entity}RGet by ID with optional relation loadingid, include
count_{Entity}RCount matching entitiesfilter
describe_{Entity}RGet entity schema, properties, relationships
create_{Entity}CCreate entityvalues (JSON object)
update_{Entity}UUpdate entityid, values (JSON object)
delete_{Entity}DDelete entityid

operations = R exposes only query/get/count/describe. operations = CRU adds create and update but not delete. RBAC applies to every tool call — even though the tool is exposed, the security policies determine what the user can actually do with it.

Optional advanced tools — opt-in via directives on the crud entry:

  tools:
    crud:
      entity = Order
      operations = R
      search = true            # adds search_{Entity} (full-text + vector)
      aggregate = true         # adds aggregate_{Entity} (GROUP BY, SUM, etc.)
    crud:
      entity = Category
      operations = RU
      traverse = true          # adds traverse_{Entity} (tree/hierarchy)
ToolOpt-inDescriptionKey parameters
search_{Entity}search = trueNatural language search (full-text + vector similarity). Only available if the entity has textSearch or vector propertiesquery, filter?, top?
aggregate_{Entity}aggregate = trueAggregation with groupingselect, groupBy, filter?, having?, orderBy?
traverse_{Entity}traverse = trueWalk a self-referential hierarchy. Only available if the entity has an EntityRef to itselfrootId, followProperty, maxDepth?, filter?

The describe_{Entity} Tool

This is the most important tool for AI clients — it returns the entity’s full schema so the client understands what it’s working with:

{
  "name": "Order",
  "description": "A customer order with line items",
  "properties": [
    {
      "name": "Status",
      "type": "String",
      "maxLength": 50,
      "required": true,
      "choice": {
        "name": "OrderStatus",
        "values": ["Draft", "Pending", "Shipped", "Delivered", "Cancelled"]
      }
    },
    {
      "name": "Total",
      "type": "Decimal",
      "precision": 18,
      "scale": 2
    },
    {
      "name": "Customer",
      "type": "EntityRef",
      "relatedEntity": "Customer"
    },
    {
      "name": "Lines",
      "type": "Collection",
      "relatedEntity": "OrderLine",
      "foreignKey": "Order"
    }
  ],
  "searchable": true,
  "searchableProperties": ["Name", "Description"],
  "vectorEnabled": false,
  "selfReferential": false,
  "relationships": {
    "outbound": [
      { "property": "Customer", "target": "Customer", "type": "EntityRef" },
      { "property": "ShippingAddress", "target": "Address", "type": "EntityRef" }
    ],
    "inbound": [
      { "property": "Lines", "source": "OrderLine", "foreignKey": "Order", "type": "Collection" }
    ]
  }
}

The relationships section gives the AI client the navigation graph — it can see that Customer.Country.Name = 'US' is a valid filter because it can trace the outbound chain Order -> Customer -> Country. The depth is capped at 3 levels to keep the response manageable.

Discovery and Platform-Provided Tools

Every MCP session gets these tools automatically, regardless of the mcp: block configuration:

ToolDescription
list_typesList all entity types the authenticated user can access. Returns name, description, property count, flags for searchable/vectorEnabled/hasHierarchy. Entry point for discovery
whoamiReturns the authenticated user’s identity: name, email, roles. Helps the client understand its permission context

list_types returns a summary of available entity types:

[
  {
    "name": "Order",
    "description": "A customer order with line items",
    "propertyCount": 12,
    "searchable": true,
    "vectorEnabled": false,
    "hasHierarchy": false
  },
  {
    "name": "Category",
    "description": "Product category with parent-child hierarchy",
    "propertyCount": 5,
    "searchable": false,
    "vectorEnabled": false,
    "hasHierarchy": true
  }
]

This lets a client discover what’s available before calling describe_{Entity} for details.

RAG Knowledge Tools (Conditionally Available)

When the app has RAG-enabled entities (any entity with rag: context = auto), the platform exposes three additional tools:

ToolDescriptionAvailability
search_knowledgeNatural language search across entity metadata snapshots, file chunks, chat messages. Hybrid vector + keyword with RRF merge. Supports entity_id scoping for relationship-scoped searchApp has any rag: context = auto entity
add_referenceCreate a soft reference (Citation, CrossReference, DerivedFrom, Bookmark, Supersedes) between two entity instancesApp has osy.Reference in model
search_referencesQuery references involving a specific entity (as source or target), optionally filtered by kindApp has osy.Reference in model

Classification filtering:

The API key user’s roles determine which classification levels are visible in search results. Classification levels are independent grants, not hierarchical — an HR user with access to PII (level 3) but not Financial (level 5) sees PII chunks but not salary data, even though 3 < 5. The SQL uses = ANY(...), not <=.

The classification chain: resolve user -> resolve roles -> resolve allowed levels -> pass to search. The filter is loaded once per session and cached.

Relationship to crud: search = true:

The search_knowledge tool and the search_{Entity} CRUD tool serve different purposes:

  • search_{Entity} — scoped to one entity type, uses that entity’s text search properties, returns entity rows
  • search_knowledge — cross-entity, searches context shadows (file chunks, snapshots, summaries), returns RAG context

Both are useful. search_knowledge finds unstructured content across the knowledge graph. search_{Entity} finds structured entity rows within a single type. An MCP client might use search_knowledge to find relevant context, then query_{Entity} to load the specific records.

Query Filter Syntax

The filter parameter on query, count, search, and aggregate tools accepts the platform’s full query filter syntax:

FeatureSyntaxExample
Comparison=, !=, <, >, <=, >=Total > 100
Pattern matchingLIKEName LIKE 'Test%'
Full-text searchMATCHESDescription MATCHES 'search terms'
MembershipIN, BETWEENStatus IN ('Active', 'Pending')
Null checksIS NULL, IS NOT NULLDueDate IS NOT NULL
LogicalAND, OR, NOTStatus = 'Active' AND Total > 100
Relation navigationDot-notationCustomer.Country.Name = 'US'
Collection traversalDot-notation -> EXISTSLines.Product.Category = 'Books'
Context references@CurrentUser, @Now, @TodayOwner = @CurrentUser
Choice references@Choice[Prop = 'Value']Status = @OrderStatus[Name = 'Active']
Collection aggregatesSUM(), COUNT(), AVG(), MIN(), MAX()COUNT(Lines) > 5

Filters are compiled to parameterized SQL server-side — no injection risk. RBAC security filters are injected automatically on top of whatever the client provides.

Property type reference:

TypeFilter literalsCreate/update value format
String'text'"text"
Int32 / Int644242
Decimal / Double3.143.14
BooleanTRUE / FALSEtrue / false
DateTime'2026-01-15T10:30:00Z'"2026-01-15T10:30:00Z"
DateOnly'2026-01-15'"2026-01-15"
Guid'550e8400-...'"550e8400-..."
JsonN/A (use property paths){ ... } (JSON object)
EntityRef= 'guid' or navigate via dot-notation"guid" (the referenced entity’s ID)
Choice= @ChoiceName[Prop = 'Value'] or = intValueintValue or "choiceLabel"

Server Instructions (Session-Level Context)

When a client connects, the MCP server sends serverInstructions as part of the session setup — not a tool, but free context injected into the session. For runtime MCP, this includes:

  • App description — what the app does (from the application: block description)
  • Domain summary — entity types with short descriptions, key relationships
  • Query syntax reference — available filter operators, dot-navigation, context references, choice refs
  • Tool catalog summary — which tool catalogs are available to this user’s roles
  • Conventions — how choice values work, how EntityRef IDs are used, date formats

The server instructions are generated dynamically from the app’s metadata at session creation time. This means the AI client can start working immediately without calling list_types + describe_ on every entity first.

No mcp: Block = No Entity/Function Tools

If an app doesn’t have an mcp: block, the /mcp endpoint returns only the platform defaults (list_types, whoami) and server instructions. No entity CRUD tools, no function tools. The endpoint is always available, but entity/function access requires explicit configuration.

Metadata Model

The mcp: block emits three metadata entities:

EntityPropertiesPurpose
McpConfigDescriptionApp-level MCP configuration (one per app)
McpToolCatalogName, Description, VisibleTo (filter expression)Grouping with role-based visibility
McpToolCatalogEntryCatalog (FK), ToolDefinition (FK), DescriptionOverrideTool binding within a catalog

McpToolCatalogEntry references the existing ToolDefinition entity — the same catalog used by agents. A function can appear in both an agent’s tool list and an MCP catalog. The ToolDefinition is shared; only the binding context differs.

Renaming mcpserver to externalMcp

The existing mcpserver block declares external MCP servers that the app’s agents can call (outbound / client side). With the addition of the mcp: block (inbound / server side), the naming becomes confusing. Renaming to externalMcp aligns with the existing api / externalApi pattern:

BlockDirectionPurpose
externalMcp GitHubMcp:Outbound (client)“My agents can call tools on this external MCP server”
mcp:Inbound (server)“External clients can call these tools on my app”
externalApi ProductApi:Outbound (client)“My functions can call this external REST API”
api:Inbound (server)“External clients can call these REST endpoints on my app”

The parser accepts both mcpserver and externalMcp during a transition period; the decompiler emits externalMcp.

Implementation

Phase 1: Slug-Based MCP Endpoint + API Key Auth

  • Add a root-level /mcp endpoint alongside the existing GUID-based design endpoint
  • Resolve the application from the subdomain routing middleware
  • Add API key authentication: check X-API-Key header, validate via the existing HMAC-SHA256 path
  • Build a security context with the authenticated user’s roles
  • Pass user context into the MCP session for RBAC enforcement

Phase 2: mcp: Block — Parser, Emitter, Metadata

  • Add AST nodes for the mcp: block with toolcatalog sections and tool entries
  • Parse tool references (function =, crud =), visibility filters, auth configuration
  • Emit McpConfig, McpToolCatalog, McpToolCatalogEntry metadata entities
  • Resolver validates that function and entity references exist, and that visibleTo filters parse correctly
  • Decompiler round-trips the mcp: block

Phase 3: Dynamic Tool Registration

  • At MCP session creation, load tool catalogs for the app
  • Evaluate visibleTo filters against the authenticated user’s roles
  • Register matching tools dynamically on the MCP server session
  • Function tools execute via the function engine with the API key user’s security context
  • CRUD tools execute via the standard data operations with RBAC filtering

Phase 4: JSON Schema + Validation

  • Generate JSON Schema from entity metadata for all tool parameters — function I/O entities and CRUD entity schemas
  • Include choice value enumeration (enum arrays), EntityRef annotations (format: "uuid" with target entity description), and standard constraints (maxLength, required)
  • Validate all tool call input against the schema before execution — invalid input never touches the database
  • Return structured error messages that give the client enough context to self-correct:
{
  "error": "VALIDATION_FAILED",
  "details": [
    { "property": "status", "message": "Value 'Xyz' is not valid for choice OrderStatus. Valid values: Draft, Pending, Shipped, Delivered, Cancelled" },
    { "property": "total", "message": "Expected number, got string" },
    { "property": "customer", "message": "Required property missing" }
  ]
}

Phase 5: Batch Tool with Preview

A platform-provided tool (available when any crud: entry has C, U, or D operations) that accepts an array of operations in one tool call, executes in a single work context, and commits atomically.

  • $ref syntax for cross-referencing entities created within the same batch
  • preview = true flag: executes all operations but skips commit, returns the change set
  • Each operation validated individually — first validation error aborts the batch
  • Security policies apply per-operation
{
  "tool": "batch",
  "arguments": {
    "preview": true,
    "operations": [
      { "action": "create", "entity": "Order", "values": { "customer": "...", "status": "Draft" }, "ref": "$order" },
      { "action": "create", "entity": "OrderLine", "values": { "order": "$order", "product": "...", "quantity": 2 } },
      { "action": "create", "entity": "OrderLine", "values": { "order": "$order", "product": "...", "quantity": 1 } }
    ]
  }
}

The client reviews the preview, then calls batch again with preview = false to commit. No server-side state held between calls — the preview is stateless. This gives AI clients “see before you commit” without the complexity of a transactional protocol.

Claude Integration Guide

1. Create an API key

osyrin user apikey generate --app myapp --user agent@myapp.com
# Output: osy_ak_abc123def456...

2. Assign roles

The API key user needs roles that grant access to the entities and functions the AI client should use:

osyrin user roles --app myapp --user agent@myapp.com --roles "Editor,ApiAccess"

3. Configure the MCP client

In Claude Code’s .mcp.json or any MCP client’s settings:

{
  "mcpServers": {
    "my-app": {
      "type": "streamable-http",
      "url": "https://myapp.osyrin.dev/mcp",
      "headers": {
        "X-API-Key": "osy_ak_abc123def456..."
      }
    }
  }
}

4. Declare which tools to expose

In the app’s DSL files, declare the mcp: block:

mcp:
  description = "Order management API"

  toolcatalog:
    name = "Order Tools"
    visibleTo = "@CurrentUser.Roles CONTAINS 'Editor'"
    tools:
      function = CreateOrder
      function = UpdateOrderStatus
      crud:
        entity = Order
        operations = CRU
        search = true
      crud:
        entity = Customer
        operations = R

5. Tool discovery is automatic

The client calls tools/list on the MCP connection and gets:

  • Function tools from catalogs matching the API key user’s roles
  • CRUD tools for entities declared in matching catalogs
  • Each tool has a typed parameter schema generated from entity metadata
  • Server instructions provide enough context to start working immediately

6. RBAC scopes what the client can do

The API key is bound to a user with specific roles. Security policies filter:

  • Which entity types the client can query/create/update/delete
  • Which properties are visible
  • Which functions the client can execute
  • Row-level filtering based on security rules

If the client tries to access something its roles don’t allow, the tool returns an error — same as any other API client.

Future Enhancements

MCP Resources

The MCP spec defines resources as server-provided data that clients can read and optionally subscribe to. The runtime equivalent would expose entity instances and schemas as addressable resources:

resource://myapp/schema/Order        -> entity type definition
resource://myapp/data/Order/550e8400-...  -> single entity instance
resource://myapp/data/Order?Status=Active -> filtered collection

Resources support resources/subscribe — the client can ask to be notified when data changes. An AI agent monitoring orders could subscribe to resource://myapp/data/Order?Status=Pending and react when new orders appear, without polling via query_Order.

Resource subscriptions require the MCP connection to stay open (SSE stream). This works for interactive sessions but not for stateless API calls. Resources complement tools, they don’t replace them.

MCP Prompts

The MCP spec supports server-defined prompts — templated instructions that clients can request and fill in:

mcp:
  prompt:
    name = "Summarize Orders"
    description = "Generate a summary of recent orders for a given time period"
    template = """
      Look at all orders created between {{startDate}} and {{endDate}}.
      Group them by status and calculate totals.
      Highlight any orders over {{threshold}} in value.
    """
    arguments:
      startDate: type = DateOnly description = "Start of the period"
      endDate: type = DateOnly description = "End of the period"
      threshold: type = Decimal description = "Value threshold for highlighting" default = 1000

Prompts differ from tools: a tool is “do this action and return a result.” A prompt is “here’s context and instructions — think about this.” The client might use multiple tools in service of a single prompt.

Prompt templates could reference entity data (e.g., {{Order.count where Status = 'Pending'}} pre-resolved server-side), turning prompts into data-aware instructions where the template embeds live query results.

Streaming Progress for Long-Running Tools

The MCP spec supports notifications/progress — the server can send progress updates during a tool call. For function tools that take time (bulk imports, report generation), the server could stream progress:

{ "method": "notifications/progress", "params": {
    "progressToken": "tool-call-123",
    "progress": 450,
    "total": 1000,
    "message": "Processing batch 5/10..."
}}

For short operations (< 5 seconds), results return directly. For longer operations, the transition from synchronous tool call to background job with streamed progress is the interesting design problem. The threshold could be configurable per tool.

Open Questions

  1. Rate limiting granularity. The rateLimit directive on the mcp: block sets a per-API-key limit. Should there also be per-catalog limits (an analytics catalog might allow more queries/sec than a write-heavy catalog)? And per-tool limits (e.g., delete_Order capped at 10/min even if the overall limit is 100/m)?

  2. OAuth flow for MCP. The auth: block supports oauth: Google, GitHub using the same syntax as api:. The MCP spec’s Streamable HTTP transport supports OAuth token exchange. Claude uses API keys; other clients (Cursor, Windsurf, custom agents) may prefer OAuth.