MCP Server
Problem
To make application data and functions available to AI clients via MCP, three things are needed:
-
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.
-
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.
-
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
- Source —
MCP(distinguishable fromRestAPI,Function,Designer,Agent) - ClientInfo — self-reported client name and version from the MCP
initializehandshake (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:
- When a client connects via MCP and authenticates with an API key, the server resolves the user’s roles.
- For each
toolcatalog, it evaluatesvisibleToagainst the user’s roles (using the same filter evaluator as security policies). - The
tools/listresponse includes only tools from catalogs the user can see. - Each
function = FuncNamereference creates a tool definition bound to the function — the same tool definition entity used by the agent system.
Syntax alignment with the agent block:
| Feature | agent: block | mcp: block |
|---|---|---|
| Tool reference | function = CreateTask | function = CreateTask (same) |
| MCP server tool | mcp = ServerName | Not applicable (mcp: is the server, not client) |
| Agent delegation | agent = SubAgent | Not applicable |
| CRUD shorthand | Not applicable | crud = EntityName |
| Grouping | flat tools: list | toolcatalog: with name and visibleTo directives |
| Visibility | all tools available to the agent | per-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.
| Tool | Operation | Description | Key parameters |
|---|---|---|---|
query_{Entity} | R | Query with filters, sorting, pagination | filter, orderBy, top, skip, include |
get_{Entity} | R | Get by ID with optional relation loading | id, include |
count_{Entity} | R | Count matching entities | filter |
describe_{Entity} | R | Get entity schema, properties, relationships | — |
create_{Entity} | C | Create entity | values (JSON object) |
update_{Entity} | U | Update entity | id, values (JSON object) |
delete_{Entity} | D | Delete entity | id |
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)
| Tool | Opt-in | Description | Key parameters |
|---|---|---|---|
search_{Entity} | search = true | Natural language search (full-text + vector similarity). Only available if the entity has textSearch or vector properties | query, filter?, top? |
aggregate_{Entity} | aggregate = true | Aggregation with grouping | select, groupBy, filter?, having?, orderBy? |
traverse_{Entity} | traverse = true | Walk a self-referential hierarchy. Only available if the entity has an EntityRef to itself | rootId, 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:
| Tool | Description |
|---|---|
list_types | List all entity types the authenticated user can access. Returns name, description, property count, flags for searchable/vectorEnabled/hasHierarchy. Entry point for discovery |
whoami | Returns 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:
| Tool | Description | Availability |
|---|---|---|
search_knowledge | Natural language search across entity metadata snapshots, file chunks, chat messages. Hybrid vector + keyword with RRF merge. Supports entity_id scoping for relationship-scoped search | App has any rag: context = auto entity |
add_reference | Create a soft reference (Citation, CrossReference, DerivedFrom, Bookmark, Supersedes) between two entity instances | App has osy.Reference in model |
search_references | Query references involving a specific entity (as source or target), optionally filtered by kind | App 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 rowssearch_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:
| Feature | Syntax | Example |
|---|---|---|
| Comparison | =, !=, <, >, <=, >= | Total > 100 |
| Pattern matching | LIKE | Name LIKE 'Test%' |
| Full-text search | MATCHES | Description MATCHES 'search terms' |
| Membership | IN, BETWEEN | Status IN ('Active', 'Pending') |
| Null checks | IS NULL, IS NOT NULL | DueDate IS NOT NULL |
| Logical | AND, OR, NOT | Status = 'Active' AND Total > 100 |
| Relation navigation | Dot-notation | Customer.Country.Name = 'US' |
| Collection traversal | Dot-notation -> EXISTS | Lines.Product.Category = 'Books' |
| Context references | @CurrentUser, @Now, @Today | Owner = @CurrentUser |
| Choice references | @Choice[Prop = 'Value'] | Status = @OrderStatus[Name = 'Active'] |
| Collection aggregates | SUM(), 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:
| Type | Filter literals | Create/update value format |
|---|---|---|
String | 'text' | "text" |
Int32 / Int64 | 42 | 42 |
Decimal / Double | 3.14 | 3.14 |
Boolean | TRUE / FALSE | true / false |
DateTime | '2026-01-15T10:30:00Z' | "2026-01-15T10:30:00Z" |
DateOnly | '2026-01-15' | "2026-01-15" |
Guid | '550e8400-...' | "550e8400-..." |
Json | N/A (use property paths) | { ... } (JSON object) |
EntityRef | = 'guid' or navigate via dot-notation | "guid" (the referenced entity’s ID) |
| Choice | = @ChoiceName[Prop = 'Value'] or = intValue | intValue 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:
| Entity | Properties | Purpose |
|---|---|---|
McpConfig | Description | App-level MCP configuration (one per app) |
McpToolCatalog | Name, Description, VisibleTo (filter expression) | Grouping with role-based visibility |
McpToolCatalogEntry | Catalog (FK), ToolDefinition (FK), DescriptionOverride | Tool 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:
| Block | Direction | Purpose |
|---|---|---|
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
/mcpendpoint alongside the existing GUID-based design endpoint - Resolve the application from the subdomain routing middleware
- Add API key authentication: check
X-API-Keyheader, 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 withtoolcatalogsections and tool entries - Parse tool references (
function =,crud =), visibility filters, auth configuration - Emit
McpConfig,McpToolCatalog,McpToolCatalogEntrymetadata entities - Resolver validates that function and entity references exist, and that
visibleTofilters parse correctly - Decompiler round-trips the
mcp:block
Phase 3: Dynamic Tool Registration
- At MCP session creation, load tool catalogs for the app
- Evaluate
visibleTofilters 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 (
enumarrays), 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.
$refsyntax for cross-referencing entities created within the same batchpreview = trueflag: 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
-
Rate limiting granularity. The
rateLimitdirective on themcp: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_Ordercapped at 10/min even if the overall limit is 100/m)? -
OAuth flow for MCP. The
auth:block supportsoauth: Google, GitHubusing the same syntax asapi:. The MCP spec’s Streamable HTTP transport supports OAuth token exchange. Claude uses API keys; other clients (Cursor, Windsurf, custom agents) may prefer OAuth.