Skip to content
Merged
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
322 changes: 1 addition & 321 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,19 +94,10 @@ export class McpPostHookProvider implements Provider<Function> {
Update your `src/application.ts` to bind the component and hooks:

```typescript
import {BootMixin, ServiceMixin} from '@loopback/core';
import {RepositoryMixin, RestApplication} from '@loopback/rest';
import {McpComponent} from 'loopback4-mcp';
import {McpHookBindings} from './keys';
import {McpPreHookProvider} from './providers/mcp-pre-hook.provider';
import {McpPostHookProvider} from './providers/mcp-post-hook.provider';

export class MyApplication extends BootMixin(
ServiceMixin(RepositoryMixin(RestApplication)),
) {
constructor(options: ApplicationConfig = {}) {
super(options);

// Bind MCP component
this.component(McpComponent);

Expand All @@ -122,26 +113,6 @@ export class MyApplication extends BootMixin(
Add the `@mcpTool()` decorator to controller methods you want to expose as MCP tools. Here's a complete example showing the decorator stack with authorization and authentication:

```typescript
import {
Count,
CountSchema,
Filter,
repository,
Where,
} from '@loopback/repository';
import {param, post, get, patch, put, del, requestBody} from '@loopback/rest';
import {authorize} from 'loopback4-authorization';
import {authenticate, STRATEGY} from 'loopback4-authentication';
import {PermissionKey} from '../permissions';
import {
OPERATION_SECURITY_SPEC,
STATUS_CODE,
getModelSchemaRefSF,
} from '@sourceloop/core';
import {mcpTool} from 'loopback4-mcp';
import {HookBindings} from '../keys';
import {User, UserRepository} from '../models';

export class UserController {
constructor(
@repository(UserRepository)
Expand Down Expand Up @@ -183,209 +154,6 @@ export class UserController {
}]
};
}

@authorize({
permissions: [PermissionKey.ViewUser],
})
@authenticate(STRATEGY.BEARER, {
passReqToCallback: true,
})
@mcpTool({
name: 'getUserById',
description: 'Get a user by ID',
preHook: {binding: HookBindings.PRE_HOOK},
postHook: {binding: HookBindings.POST_HOOK},
})
@get('/users/{id}', {
security: OPERATION_SECURITY_SPEC,
responses: {
[STATUS_CODE.OK]: {
description: 'User model instance',
content: {
'application/json': {
schema: getModelSchemaRefSF(User, {includeRelations: true}),
},
},
},
},
})
async findById(
@param.path.string('id') id: string,
): Promise<User> {
return this.userRepository.findById(id);
}

@authorize({
permissions: [PermissionKey.ViewUser],
})
@authenticate(STRATEGY.BEARER, {
passReqToCallback: true,
})
@mcpTool({
name: 'listUsers',
description: 'List all users',
preHook: {binding: HookBindings.PRE_HOOK},
postHook: {binding: HookBindings.POST_HOOK},
})
@get('/users', {
security: OPERATION_SECURITY_SPEC,
responses: {
[STATUS_CODE.OK]: {
description: 'Array of User model instances',
content: {
'application/json': {
schema: {
type: 'array',
items: getModelSchemaRefSF(User, {includeRelations: true}),
},
},
},
},
},
})
async find(
@param.filter(User) filter?: Filter<User>,
): Promise<User[]> {
return this.userRepository.find(filter);
}

@authorize({
permissions: [PermissionKey.UpdateUser],
})
@authenticate(STRATEGY.BEARER, {
passReqToCallback: true,
})
@mcpTool({
name: 'updateUserById',
description: 'Update a user by ID',
preHook: {binding: HookBindings.PRE_HOOK},
postHook: {binding: HookBindings.POST_HOOK},
})
@patch('/users/{id}', {
security: OPERATION_SECURITY_SPEC,
responses: {
[STATUS_CODE.NO_CONTENT]: {
description: 'User PATCH success',
},
},
})
async updateById(
@param.path.string('id') id: string,
@param.query.object('user') user: User,
): Promise<object> {
await this.userRepository.updateById(id, user);

return {
content: [{
type: 'text',
text: `Successfully updated user with id: ${id}`
}]
};
}

@authorize({
permissions: [PermissionKey.DeleteUser],
})
@authenticate(STRATEGY.BEARER, {
passReqToCallback: true,
})
@mcpTool({
name: 'deleteUser',
description: 'Delete a user by ID',
preHook: {binding: HookBindings.PRE_HOOK},
postHook: {binding: HookBindings.POST_HOOK},
})
@del('/users/{id}', {
security: OPERATION_SECURITY_SPEC,
responses: {
[STATUS_CODE.NO_CONTENT]: {
description: 'User DELETE success',
},
},
})
async deleteById(@param.path.string('id') id: string): Promise<object> {
// Verify user exists first (will throw 404 if not found)
await this.userRepository.findById(id);

await this.userRepository.deleteById(id);

return {
content: [{
type: 'text',
text: `Successfully deleted user with id: ${id}`
}]
};
}
}
```

## Component Interaction Flow

**Request Flow:**
```
Client Request (POST /mcp?tool=toolName)
McpController receives request and creates MCP server
McpServerFactory generates per-request server instance
McpToolRegistry looks up tool by name
Authorization Check validates JWT and permissions
Pre-Hook (if configured) performs validation/sanitization
Controller Method executes business logic
Post-Hook (if configured) performs logging/audit trails
Response Formatting wraps result in MCP format
Client receives MCP-formatted response
```

**Error Flow:**
```
Authorization Check Failed → 403 Forbidden Response → Client receives error
Controller Method Error → Error Formatting → Client receives MCP-formatted error
Hook Execution Error → Error Handling → Client receives MCP-formatted error
```

## MCP Endpoint Usage

### Endpoint Format

**POST** `/mcp?tool=toolName`

**Required Headers:**
- `Content-Type: application/json`
- `Authorization: Bearer YOUR_JWT_TOKEN`

### Example Request

```bash
curl -X POST http://localhost:3000/mcp?tool=create-user \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{
"user": {
"email": "john@example.com",
"name": "John Doe",
"age": 30
}
}'
```

### Example Response

```json
{
"content": [
{
"type": "text",
"text": "User created with ID: 550e8400-e29b-41d4-a716-446655440000"
}
]
}
```

Expand Down Expand Up @@ -474,95 +242,6 @@ npx @modelcontextprotocol/inspector http://localhost:3000/mcp
5. Monitor hook execution and responses
6. Debug issues using the detailed logs

## Troubleshooting

### Common Issues

**Parameter extraction failures**
- **Cause:** Missing or incorrect `@param` decorators
- **Solution:** Ensure all parameters have appropriate decorators based on route structure

**"Invalid tools/call result: expected object, received undefined"**
- **Cause:** Method returns `void` instead of object
- **Solution:** Always return explicit MCP-formatted response for non-read operations

**Hook not executing**
- **Cause:** Hook not bound in application.ts or binding key mismatch
- **Solution:** Verify hook providers are bound and binding keys match decorator configuration

**Authorization failures**
- **Cause:** Missing `@authorize()` decorator or invalid JWT token
- **Solution:** Add appropriate authorization decorators and ensure valid authentication

## Best Practices

1. **Always use `@param` decorators** - MCP tool will fail without them
2. **Return MCP-formatted responses** for write operations (CREATE, UPDATE, DELETE)
3. **Implement proper error handling** in controller methods and hooks
4. **Use hooks for cross-cutting concerns** - validation, logging, audit trails
5. **Test with curl first** before integrating with MCP clients
6. **Monitor hook execution time** - hooks should be fast and non-blocking
7. **Keep controller methods focused** - move complex logic to services
8. **Use descriptive tool names** and detailed descriptions for better discovery

## Advanced Features

### Zod Schema Validation

Add input validation using Zod schemas:

```typescript
import {z} from 'zod';

@mcpTool({
name: 'create-user',
description: 'Create a new user',
schema: {
email: z.string().email('Invalid email format'),
name: z.string().min(2, 'Name must be at least 2 characters'),
age: z.number().min(18, 'User must be 18 or older'),
},
})
```

### Custom Hook Implementations

Create more sophisticated hooks for business logic:

```typescript
export class ValidationHookProvider implements Provider<Function> {
value(): Function {
return async (context: McpHookContext) => {
if (context.toolName === 'create-user') {
const user = context.args.user as any;

// Validate email uniqueness
const existing = await this.userRepository.findOne({
where: {email: user.email}
});

if (existing) {
throw new Error('User with this email already exists');
}

// Sanitize input
user.name = user.name.trim();
user.email = user.email.toLowerCase();
}
};
}
}
```

## Complete Example

For a complete working example, refer to the test suite in `src/__tests__/` which demonstrates:

- Controller setup with `@mcpTool` decorators
- Hook provider implementations
- Integration with authorization system
- Request/response handling
- Error management

## License

Expand All @@ -572,3 +251,4 @@ For a complete working example, refer to the test suite in `src/__tests__/` whic

- GitHub Issues: [sourcefuse/loopback4-mcp/issues](https://github.com/sourcefuse/loopback4-mcp/issues)
- Documentation: [loopback.io](https://loopback.io/)

Loading