Files
mcpctl/.taskmaster/tasks/task_027.md
Michal a869f77070 merge: AgentProject membership — the junction is now load-bearing
Merges feat/agent-project-membership (task 166/152). An agent can finally join
many projects: membership routes, per-membership role and optional brain
override, chat resolving context from the membership, and CLI attach-agent /
detach-agent / get memberships. The legacy Agent.projectId fallback is kept for
this release as the schema comment promises.

Conflict resolved in main.ts: that branch added its RBAC case to
mapUrlToPermission inline, while this branch had extracted the function to
middleware/url-permission.ts so it could be tested. Kept the extraction and
ported the case into it — joining a team is `edit:agents:<name>`, not
`create:agents`, which is the authority to mint a whole agent.

Also fixes something the extraction makes possible. security.test.ts contained a
hand-copied reproduction of mapUrlToPermission, because main.ts self-invokes
main() and nothing in it was importable. A security test asserting against a
copy stops testing the system the moment the real one changes, and this copy had
already drifted. It now imports the real function; all 18 assertions still pass,
against the code that actually runs.

Local: build clean, workspace 2550 passed, lint unchanged at 794 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-09 23:34:18 +01:00

4.6 KiB

Task ID: 27

Title: Implement mcplocal management proxy routes

Status: done

Dependencies: 26 ✓

Priority: high

Description: Add REST endpoints to mcplocal that mirror mcpd's API and proxy management requests to mcpd without LLM processing. All requests must include proper authentication to mcpd using a Bearer token read from mcplocal config.

Details:

  1. Create HTTP client for mcpd communication with auth: src/local-proxy/src/http/mcpd-client.ts
    export class McpdClient {
      private token: string;
    
      constructor(private baseUrl: string, token: string) {
        this.token = token;
      }
    
      private getHeaders(): Record<string, string> {
        return {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${this.token}`
        };
      }
    
      async get<T>(path: string): Promise<T> {
        const response = await fetch(`${this.baseUrl}${path}`, {
          method: 'GET',
          headers: this.getHeaders()
        });
        await this.handleAuthError(response);
        return response.json();
      }
    
      async post<T>(path: string, body: unknown): Promise<T> {
        const response = await fetch(`${this.baseUrl}${path}`, {
          method: 'POST',
          headers: this.getHeaders(),
          body: JSON.stringify(body)
        });
        await this.handleAuthError(response);
        return response.json();
      }
    
      async put<T>(path: string, body: unknown): Promise<T> {
        const response = await fetch(`${this.baseUrl}${path}`, {
          method: 'PUT',
          headers: this.getHeaders(),
          body: JSON.stringify(body)
        });
        await this.handleAuthError(response);
        return response.json();
      }
    
      async delete<T>(path: string): Promise<T> {
        const response = await fetch(`${this.baseUrl}${path}`, {
          method: 'DELETE',
          headers: this.getHeaders()
        });
        await this.handleAuthError(response);
        return response.json();
      }
    
      private async handleAuthError(response: Response): Promise<void> {
        if (response.status === 401) {
          throw new AuthenticationError('Invalid or expired token. Please check mcplocal config.');
        }
      }
    }
    
    export class AuthenticationError extends Error {
      constructor(message: string) {
        super(message);
        this.name = 'AuthenticationError';
      }
    }
    
  2. Add token to mcplocal config type (extend ProxyConfig or similar):
    export interface McpdAuthConfig {
      /** Bearer token for mcpd API authentication */
      mcpdToken: string;
    }
    
  3. Create proxy routes in src/local-proxy/src/http/routes/:
    • servers.ts: GET/POST /api/v1/servers, GET/PUT/DELETE /api/v1/servers/:id
    • profiles.ts: GET/POST /api/v1/profiles, GET/PUT/DELETE /api/v1/profiles/:id
    • instances.ts: GET/POST /api/v1/instances, GET/POST/DELETE /api/v1/instances/:id, etc.
    • projects.ts: GET/POST /api/v1/projects, etc.
    • audit.ts: GET /api/v1/audit-logs
    • backup.ts: POST /api/v1/backup, POST /api/v1/restore
  4. Each route handler forwards to mcpd with auth:
    app.get('/api/v1/servers', async (req, reply) => {
      try {
        const result = await mcpdClient.get('/api/v1/servers');
        return result;
      } catch (error) {
        if (error instanceof AuthenticationError) {
          return reply.status(401).send({ error: error.message });
        }
        throw error;
      }
    });
    
  5. Add comprehensive error handling:
    • If mcpd is unreachable, return 503 Service Unavailable
    • If mcpd returns 401, return 401 with clear message about token configuration
    • Forward other HTTP errors from mcpd with appropriate status codes
  6. Add request/response logging for debugging

Test Strategy:

  1. Unit test: McpdClient attaches Authorization header to all request methods (GET, POST, PUT, DELETE)
  2. Unit test: McpdClient throws AuthenticationError on 401 response from mcpd
  3. Unit test: Each proxy route forwards requests correctly with auth headers
  4. Unit test: Error handling when mcpd is unreachable (503 response)
  5. Unit test: Error handling when mcpd returns 401 (clear error message returned)
  6. Integration test: Full request flow mcpctl -> mcplocal -> mcpd with valid token
  7. Integration test: Full request flow with invalid token returns 401
  8. Test query parameters are forwarded correctly
  9. Test request body is forwarded correctly for POST/PUT
  10. Test path parameters (:id) are passed through correctly
  11. Mock mcpd responses and verify mcplocal returns them unchanged
  12. Test token is read correctly from mcplocal config