php-mcp/docs/workspace.md
Snider 1a95091b9c docs: add package documentation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:47:52 +00:00

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-ID is 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);
}

Learn More