Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions playground/src/errors/base.error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export class BaseError extends Error {
public readonly status: number
public readonly code: string
public readonly metadata: Record<string, any>

constructor(
message: string,
status: number = 500,
code: string = 'INTERNAL_SERVER_ERROR',
metadata: Record<string, any> = {}
) {
super(message)
this.name = this.constructor.name
this.status = status
this.code = code
this.metadata = metadata

// Maintains proper stack trace for where our error was thrown
Error.captureStackTrace(this, this.constructor)
}

toJSON() {
return {
error: {
name: this.name,
message: this.message,
code: this.code,
status: this.status,
...(Object.keys(this.metadata).length > 0 && { metadata: this.metadata }),
},
}
}
}

61 changes: 61 additions & 0 deletions playground/src/errors/mcp.error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { BaseError } from './base.error'

/**
* Error thrown when MCP origin validation fails
*/
export class MCPOriginError extends BaseError {
constructor(origin: string | undefined) {
super(
`Origin '${origin}' is not allowed. Only localhost and 127.0.0.1 are permitted.`,
403,
'MCP_ORIGIN_NOT_ALLOWED',
{ origin }
)
}
}

/**
* Error thrown when MCP method is not found
*/
export class MCPMethodNotFoundError extends BaseError {
constructor(method: string) {
super(`MCP method '${method}' is not supported`, 400, 'MCP_METHOD_NOT_FOUND', { method })
}
}

/**
* Error thrown when MCP tool is not found
*/
export class MCPToolNotFoundError extends BaseError {
constructor(toolName: string) {
super(`MCP tool '${toolName}' does not exist`, 404, 'MCP_TOOL_NOT_FOUND', { toolName })
}
}

/**
* Error thrown when MCP resource is not found
*/
export class MCPResourceNotFoundError extends BaseError {
constructor(uri: string) {
super(`MCP resource '${uri}' does not exist`, 404, 'MCP_RESOURCE_NOT_FOUND', { uri })
}
}

/**
* Error thrown when MCP prompt is not found
*/
export class MCPPromptNotFoundError extends BaseError {
constructor(promptName: string) {
super(`MCP prompt '${promptName}' does not exist`, 404, 'MCP_PROMPT_NOT_FOUND', { promptName })
}
}

/**
* Error thrown when MCP request is invalid
*/
export class MCPInvalidRequestError extends BaseError {
constructor(details: string) {
super(`Invalid MCP request: ${details}`, 400, 'MCP_INVALID_REQUEST', { details })
}
}

46 changes: 46 additions & 0 deletions playground/src/middlewares/core.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ApiMiddleware } from 'motia'
import { ZodError } from 'zod'
import { BaseError } from '../errors/base.error'

export const coreMiddleware: ApiMiddleware = async (req, ctx, next) => {
const logger = ctx.logger

try {
return await next()
} catch (error: any) {
if (error instanceof ZodError) {
logger.error('Validation error', {
error,
stack: error.stack,
errors: error.errors,
})

return {
status: 400,
body: {
error: 'Invalid request body',
data: error.errors,
},
}
} else if (error instanceof BaseError) {
logger.error('BaseError', {
status: error.status,
code: error.code,
metadata: error.metadata,
name: error.name,
message: error.message,
})

return { status: error.status, body: error.toJSON() }
}

logger.error('Error while performing request', {
error,
body: req.body,
stack: error.stack,
})

return { status: 500, body: { error: 'Internal Server Error' } }
}
}

36 changes: 36 additions & 0 deletions playground/src/middlewares/origin-validation.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ApiMiddleware } from 'motia'
import { MCPOriginError } from '../errors/mcp.error'

/**
* Middleware to validate Origin header according to MCP specification.
* This prevents DNS rebinding attacks by ensuring requests come from trusted origins.
*
* Per MCP spec: Servers MUST validate the Origin header on all incoming connections
* to prevent DNS rebinding attacks.
*/
export const originValidationMiddleware: ApiMiddleware = async (req, ctx, next) => {
const logger = ctx.logger

// Get the Origin header (can be string or array of strings)
const originHeader = req.headers.origin
const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader

logger.debug('Validating origin header', { origin })

// Validate the origin - allow localhost and 127.0.0.1 on any port
// Only allow http:// for local development
// Also allow requests without Origin header for MCP Inspector compatibility
if (origin) {
if (!origin.startsWith('http://localhost') && !origin.startsWith('http://127.0.0.1')) {
logger.warn('Origin validation failed', { origin })
throw new MCPOriginError(origin)
}
} else {
// No origin header - likely from MCP Inspector proxy or similar tools
logger.debug('No origin header present - allowing request for MCP Inspector compatibility')
}

logger.debug('Origin validation passed', { origin })
return await next()
}

10 changes: 10 additions & 0 deletions playground/src/services/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* MCP Service exports
* Following Domain-Driven Design pattern
*/

export * from './tools'
export * from './resources'
export * from './prompts'
export * from './motia-introspection'

171 changes: 171 additions & 0 deletions playground/src/services/mcp/motia-introspection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Service to access Motia internals (LockedData, steps, flows, etc.)
*
* This service provides a way for MCP tools to introspect the Motia application.
* We'll populate this with a reference to LockedData when the server starts.
*/

import type { Step, Flow } from 'motia'

/**
* Singleton to hold reference to Motia internals
*/
class MotiaIntrospectionService {
private static instance: MotiaIntrospectionService
private lockedData: any = null

private constructor() {}

static getInstance(): MotiaIntrospectionService {
if (!MotiaIntrospectionService.instance) {
MotiaIntrospectionService.instance = new MotiaIntrospectionService()
}
return MotiaIntrospectionService.instance
}

/**
* Initialize with LockedData reference
* This will be called from the MCP endpoint when it has access to lockedData
*/
setLockedData(lockedData: any) {
this.lockedData = lockedData
}

/**
* Check if LockedData is available
*/
isInitialized(): boolean {
return this.lockedData !== null
}

/**
* Get all active steps
*/
getActiveSteps(): Step[] {
if (!this.isInitialized()) {
console.warn('MotiaIntrospection: LockedData not initialized')
return []
}
return this.lockedData?.activeSteps || []
}

/**
* Get all dev steps (NOOP steps)
*/
getDevSteps(): Step[] {
if (!this.isInitialized()) {
console.warn('MotiaIntrospection: LockedData not initialized')
return []
}
return this.lockedData?.devSteps || []
}

/**
* Get all steps (active + dev)
*/
getAllSteps(): Step[] {
return [...this.getActiveSteps(), ...this.getDevSteps()]
}

/**
* Get a step by name
*/
getStepByName(name: string): Step | undefined {
return this.getAllSteps().find((step) => step.config.name === name)
}

/**
* Get steps by type
*/
getStepsByType(type: 'api' | 'event' | 'cron' | 'noop'): Step[] {
return this.getAllSteps().filter((step) => step.config.type === type)
}

/**
* Get all flows
*/
getFlows(): Record<string, Flow> {
if (!this.isInitialized()) {
console.warn('MotiaIntrospection: LockedData not initialized')
return {}
}
return this.lockedData?.flows || {}
}

/**
* Get a flow by name
*/
getFlowByName(name: string): Flow | undefined {
return this.getFlows()[name]
}

/**
* Get steps in a flow
*/
getStepsInFlow(flowName: string): Step[] {
const flow = this.getFlowByName(flowName)
return flow?.steps || []
}

/**
* Check if a topic exists (has subscribers)
*/
topicExists(topic: string): boolean {
const eventSteps = this.getStepsByType('event')
if (eventSteps.length === 0) {
// If no event steps found, assume topic might exist (optimistic)
console.warn(
'MotiaIntrospection: Cannot validate topic existence without LockedData'
)
return true
}
return eventSteps.some((step) => {
const config = step.config as any
return config.subscribes?.includes(topic)
})
}

/**
* Get all available topics (from emits and subscribes)
*/
getAllTopics(): string[] {
const topics = new Set<string>()

this.getAllSteps().forEach((step) => {
const config = step.config as any

// Add emits
if (config.emits) {
config.emits.forEach((emit: string | { topic: string }) => {
const topic = typeof emit === 'string' ? emit : emit.topic
topics.add(topic)
})
}

// Add subscribes (for event steps)
if (config.subscribes) {
config.subscribes.forEach((topic: string) => topics.add(topic))
}
})

return Array.from(topics)
}

/**
* Get API endpoints
*/
getApiEndpoints(): Array<{ path: string; method: string; name: string }> {
const apiSteps = this.getStepsByType('api')
return apiSteps.map((step) => {
const config = step.config as any
return {
path: config.path || '',
method: config.method || '',
name: config.name || '',
}
})
}
}

export const motiaIntrospection = MotiaIntrospectionService.getInstance()

Loading
Loading