7.6 KiB
7.6 KiB
Workspace Context
Workspace isolation and context resolution for MCP tools.
Overview
Workspace context ensures that MCP tools operate within the correct workspace boundary, preventing data leaks and unauthorized access.
Context Resolution
From Request Headers
use Core\Mcp\Context\WorkspaceContext;
// Resolve from X-Workspace-ID header
$context = WorkspaceContext::fromRequest($request);
// Returns WorkspaceContext with:
// - workspace: Workspace model
// - user: Current user
// - namespace: Current namespace (if applicable)
Request Example:
curl -H "Authorization: Bearer sk_live_..." \
-H "X-Workspace-ID: ws_abc123" \
https://api.example.com/mcp/query
From API Key
use Mod\Api\Models\ApiKey;
$apiKey = ApiKey::findByKey($providedKey);
// API key is scoped to workspace
$context = WorkspaceContext::fromApiKey($apiKey);
Manual Creation
use Mod\Tenant\Models\Workspace;
$workspace = Workspace::find($id);
$context = new WorkspaceContext(
workspace: $workspace,
user: $user,
namespace: $namespace
);
Requiring Context
Tool Implementation
<?php
namespace Mod\Blog\Mcp\Tools;
use Core\Mcp\Tools\BaseTool;
use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext;
class ListPosts extends BaseTool
{
use RequiresWorkspaceContext;
public function execute(array $params): array
{
// Validates workspace context exists
$this->validateWorkspaceContext();
// Access workspace
$workspace = $this->workspaceContext->workspace;
// Query scoped to workspace
return Post::where('workspace_id', $workspace->id)
->where('status', $params['status'] ?? 'published')
->get()
->toArray();
}
}
Middleware
use Core\Mcp\Middleware\ValidateWorkspaceContext;
Route::middleware([ValidateWorkspaceContext::class])
->post('/mcp/tools/{tool}', [McpController::class, 'execute']);
Validation:
- Header
X-Workspace-IDis present - Workspace exists
- User has access to workspace
- API key is scoped to workspace
Automatic Query Scoping
SELECT Queries
// Query without workspace filter
$result = $tool->execute([
'query' => 'SELECT * FROM posts WHERE status = ?',
'bindings' => ['published'],
]);
// Automatically becomes:
// SELECT * FROM posts
// WHERE status = ?
// AND workspace_id = ?
// With bindings: ['published', $workspaceId]
BelongsToWorkspace Models
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
class Post extends Model
{
use BelongsToWorkspace;
// Automatically scoped to workspace
}
// All queries automatically filtered:
Post::all(); // Only current workspace's posts
Post::where('status', 'published')->get(); // Scoped
Post::find($id); // Returns null if wrong workspace
Context Properties
Workspace
$workspace = $context->workspace;
$workspace->id; // Workspace ID
$workspace->name; // Workspace name
$workspace->slug; // URL slug
$workspace->settings; // Workspace settings
$workspace->subscription; // Subscription plan
User
$user = $context->user;
$user->id; // User ID
$user->name; // User name
$user->email; // User email
$user->workspace_id; // Primary workspace
$user->permissions; // User permissions
Namespace
$namespace = $context->namespace;
if ($namespace) {
$namespace->id; // Namespace ID
$namespace->name; // Namespace name
$namespace->entitlements; // Feature access
}
Multi-Workspace Access
Switching Context
// User with access to multiple workspaces
$workspaces = $user->workspaces;
foreach ($workspaces as $workspace) {
$context = new WorkspaceContext($workspace, $user);
// Execute in workspace context
$result = $tool->execute($params, $context);
}
Cross-Workspace Queries (Admin)
// Requires admin permission
$result = $tool->execute([
'query' => 'SELECT * FROM posts',
'bypass_workspace_scope' => true,
], $context);
// Returns posts from all workspaces
Error Handling
Missing Context
use Core\Mcp\Exceptions\MissingWorkspaceContextException;
try {
$tool->execute($params); // No context provided
} catch (MissingWorkspaceContextException $e) {
return response()->json([
'error' => 'Workspace context required',
'message' => 'Please provide X-Workspace-ID header',
], 400);
}
Invalid Workspace
use Core\Mod\Tenant\Exceptions\WorkspaceNotFoundException;
try {
$context = WorkspaceContext::fromRequest($request);
} catch (WorkspaceNotFoundException $e) {
return response()->json([
'error' => 'Invalid workspace',
'message' => 'Workspace not found',
], 404);
}
Unauthorized Access
use Illuminate\Auth\Access\AuthorizationException;
try {
$context = WorkspaceContext::fromRequest($request);
} catch (AuthorizationException $e) {
return response()->json([
'error' => 'Unauthorized',
'message' => 'You do not have access to this workspace',
], 403);
}
Testing
use Tests\TestCase;
use Core\Mcp\Context\WorkspaceContext;
class WorkspaceContextTest extends TestCase
{
public function test_resolves_from_header(): void
{
$workspace = Workspace::factory()->create();
$response = $this->withHeaders([
'X-Workspace-ID' => $workspace->id,
])->postJson('/mcp/query', [...]);
$response->assertStatus(200);
}
public function test_scopes_queries_to_workspace(): void
{
$workspace1 = Workspace::factory()->create();
$workspace2 = Workspace::factory()->create();
Post::factory()->create(['workspace_id' => $workspace1->id]);
Post::factory()->create(['workspace_id' => $workspace2->id]);
$context = new WorkspaceContext($workspace1);
$result = $tool->execute([
'query' => 'SELECT * FROM posts',
], $context);
$this->assertCount(1, $result['rows']);
$this->assertEquals($workspace1->id, $result['rows'][0]['workspace_id']);
}
public function test_throws_when_context_missing(): void
{
$this->expectException(MissingWorkspaceContextException::class);
$tool->execute(['query' => 'SELECT * FROM posts']);
}
}
Best Practices
1. Always Validate Context
// ✅ Good - validate context
public function execute(array $params)
{
$this->validateWorkspaceContext();
// ...
}
// ❌ Bad - no validation
public function execute(array $params)
{
// Potential workspace bypass
}
2. Use BelongsToWorkspace Trait
// ✅ Good - automatic scoping
class Post extends Model
{
use BelongsToWorkspace;
}
// ❌ Bad - manual filtering
Post::where('workspace_id', $workspace->id)->get();
3. Provide Clear Errors
// ✅ Good - helpful error
throw new MissingWorkspaceContextException(
'Please provide X-Workspace-ID header'
);
// ❌ Bad - generic error
throw new Exception('Error');
4. Test Context Isolation
// ✅ Good - test workspace boundaries
public function test_cannot_access_other_workspace(): void
{
$workspace1 = Workspace::factory()->create();
$workspace2 = Workspace::factory()->create();
$context = new WorkspaceContext($workspace1);
$post = Post::factory()->create(['workspace_id' => $workspace2->id]);
$result = Post::find($post->id); // Should be null
$this->assertNull($result);
}