dealspace/MCP-SPEC.md

36 KiB

Dealspace MCP Server — Technical Specification

Version: 0.1 — 2026-02-28
Status: Pre-implementation
Companion to: SPEC.md (core platform specification)


1. Overview

The Dealspace MCP (Model Context Protocol) server exposes deal context to AI tools. It enables LLMs to assist with deal workflows — reading request status, querying answers, suggesting routing — while maintaining strict RBAC boundaries.

Core principle: The AI sees exactly what the authenticated user sees. No privilege escalation, no cross-project leakage, no pre-dataroom content without explicit unlock.

1.1 Endpoint

Production: https://dealspace.com/mcp
Staging: https://staging.dealspace.com/mcp

1.2 Protocol

  • Transport: Streamable HTTP (MCP Specification 2025-06-18)
  • Protocol Version: 2025-06-18
  • Server Name: dealspace
  • Server Version: 1.0.0

2. Authentication & Session Context

2.1 OAuth 2.0 Flow

MCP uses standard OAuth 2.0 Authorization Code flow with PKCE:

Endpoint URL
Authorization https://dealspace.com/oauth/authorize
Token Exchange https://dealspace.com/oauth/token
UserInfo https://dealspace.com/oauth/userinfo
Revocation https://dealspace.com/oauth/revoke
Server Metadata https://dealspace.com/.well-known/oauth-authorization-server
Protected Resource Metadata https://dealspace.com/.well-known/oauth-protected-resource

2.2 Access Token Scopes

Scope Description
read:projects List accessible projects
read:workstreams Read workstream structure and entries
read:requests Read request details and status
read:answers Read published answers (respects dataroom gating)
read:events Read workflow events and thread history
write:routing Suggest routing changes (requires human confirmation)
unlock:pre_dataroom Access pre-dataroom content (explicit opt-in)

Default scope for MCP clients: read:projects read:workstreams read:requests read:answers read:events write:routing

The unlock:pre_dataroom scope is never granted by default — requires explicit user consent at authorization time.

2.3 Session Context

Every MCP request includes:

  1. Actor ID — extracted from OAuth token (the authenticated user)
  2. Project ID — passed as parameter to project-scoped tools, or session-bound after set_project
POST /mcp HTTP/1.1
Host: dealspace.com
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "list_requests",
    "arguments": {
      "project_id": "proj_abc123",
      "workstream": "finance"
    }
  }
}

The MCP handler:

  1. Extracts access token from Authorization header
  2. Looks up session → gets actor_id and granted scopes
  3. Creates AccessContext{ActorID, ProjectID, Scopes}
  4. Routes to tool handler with context
  5. All data access goes through CheckAccess() — RBAC enforced at DB layer

2.4 Project Binding

Two modes of operation:

Explicit project_id per call:

{"name": "list_requests", "arguments": {"project_id": "proj_abc123", "workstream": "finance"}}

Session-bound project (via set_project):

{"name": "set_project", "arguments": {"project_id": "proj_abc123"}}
// subsequent calls omit project_id — uses session binding
{"name": "list_requests", "arguments": {"workstream": "finance"}}

Session binding is per-OAuth-session. Token refresh preserves binding. Token revocation clears it.


3. Gating Mechanism (Pre-Dataroom Tier)

3.1 The Problem

Answers go through multiple stages before publication:

draft → submitted → approved → rejected → published

Pre-dataroom content (anything not Stage = "dataroom" or status not "published") is sensitive:

  • Contains unvetted information
  • May reveal negotiation strategy
  • Buyers should never see unpublished content

3.2 The Solution: Explicit Unlock

By default, MCP tools cannot access pre-dataroom content — even if the user's role theoretically permits it via the web UI.

Why: LLMs process data through external inference. Even with zero-retention guarantees, the user should consciously decide: "I want AI assistance on this sensitive content."

How it works:

  1. Default state: unlock:pre_dataroom scope NOT in token
  2. Tool behavior: Pre-dataroom entries filtered at query time
  3. Unlock flow: User re-authorizes with explicit scope consent
  4. Unlock result: Token includes unlock:pre_dataroom, tools return full data

3.3 Scope Check in Tools

func (h *MCPHandler) listRequests(ctx AccessContext, args ListRequestsArgs) (*ListRequestsResult, error) {
    filter := EntryFilter{
        ProjectID:  args.ProjectID,
        Type:       "request",
        Workstream: args.Workstream,
    }
    
    // Gate: unless explicitly unlocked, only show dataroom content
    if !ctx.HasScope("unlock:pre_dataroom") {
        filter.Stage = "dataroom"
        filter.Status = []string{"published"}
    }
    
    entries, err := lib.EntryRead(ctx.ActorID, args.ProjectID, filter)
    if err != nil {
        return nil, err
    }
    // ...
}

3.4 User Experience

When user asks AI about unpublished content:

User: "Show me the draft answers for the IT workstream"

AI: I can only access published content by default. You have draft answers
    in the IT workstream, but viewing them requires explicit authorization.
    
    [Link: Authorize access to unpublished content]
    
    This ensures you consciously decide when AI tools can see sensitive
    pre-publication data.

Re-authorization link triggers OAuth flow with unlock:pre_dataroom scope. User sees clear consent screen explaining what they're unlocking.


4. Tool Definitions

4.1 Project Tools

list_projects

List all projects the actor can access.

Parameters: None

Returns:

{
  "projects": [
    {
      "project_id": "proj_abc123",
      "name": "Acme Corp Acquisition",
      "role": "ib_member",
      "workstreams": ["finance", "legal", "it"],
      "stage": "dataroom",
      "request_count": 142,
      "pending_count": 23
    }
  ]
}

RBAC: Returns only projects where actor has any role.


set_project

Bind session to a project for subsequent calls.

Parameters:

Name Type Required Description
project_id string yes Project to bind

Returns:

{
  "project_id": "proj_abc123",
  "name": "Acme Corp Acquisition",
  "role": "ib_member",
  "workstreams": ["finance", "legal", "it"]
}

RBAC: Fails if actor has no access to project.


get_project_summary

High-level deal status — completion rates, blockers, timeline.

Parameters:

Name Type Required Description
project_id string no Defaults to session-bound project

Returns:

{
  "project_id": "proj_abc123",
  "name": "Acme Corp Acquisition",
  "stage": "dataroom",
  "workstreams": [
    {
      "name": "finance",
      "total_requests": 45,
      "completed": 38,
      "pending": 5,
      "overdue": 2,
      "completion_pct": 84.4
    }
  ],
  "overall_completion_pct": 78.2,
  "blockers": [
    {"workstream": "legal", "request_ref": "LEG-012", "reason": "awaiting external counsel"}
  ],
  "next_milestone": "Buyer access opens 2026-03-15"
}

RBAC:

  • IB roles: full summary
  • Seller roles: seller-relevant workstreams only
  • Buyer roles: published answer counts only (no pending/overdue visibility)

4.2 Workstream Tools

list_workstreams

List workstreams in a project with role-appropriate detail.

Parameters:

Name Type Required Description
project_id string no Defaults to session-bound project

Returns:

{
  "workstreams": [
    {
      "workstream_id": "ws_fin001",
      "name": "finance",
      "display_name": "Finance & Accounting",
      "role": "ib_member",
      "request_lists": [
        {"list_id": "rl_001", "name": "Initial Request List", "request_count": 42}
      ]
    }
  ]
}

RBAC: Only workstreams where actor has explicit access.


get_workstream_summary

Detailed workstream status.

Parameters:

Name Type Required Description
project_id string no Defaults to session-bound project
workstream string yes Workstream slug (e.g., "finance")

Returns:

{
  "workstream": "finance",
  "display_name": "Finance & Accounting",
  "status": {
    "total_requests": 45,
    "by_status": {
      "open": 3,
      "assigned": 5,
      "answered": 8,
      "vetted": 6,
      "published": 23
    },
    "overdue": [
      {"ref": "FIN-042", "title": "Audited financials FY2024", "due": "2026-03-01", "days_overdue": 3}
    ]
  },
  "recent_activity": [
    {"ts": 1709123456, "actor": "Jane Smith", "action": "published", "ref": "FIN-039"}
  ]
}

RBAC:

  • IB/Seller with workstream access: full detail
  • Buyers: published counts only
  • Pre-dataroom gating applies to overdue/activity details

4.3 Request Tools

list_requests

List requests in a workstream, filtered by status.

Parameters:

Name Type Required Description
project_id string no Defaults to session-bound project
workstream string yes Workstream slug
status string[] no Filter by status (default: all visible)
assigned_to string no Filter by assignee user_id
priority string no Filter by priority
limit int no Max results (default: 50, max: 200)
offset int no Pagination offset

Returns:

{
  "requests": [
    {
      "entry_id": "ent_req001",
      "ref": "FIN-042",
      "title": "Audited financials FY2024",
      "status": "assigned",
      "priority": "high",
      "due_date": "2026-03-15",
      "assigned_to": [{"user_id": "usr_001", "name": "John Doe"}],
      "origin": "buyer",
      "answer_count": 0
    }
  ],
  "total": 45,
  "offset": 0,
  "limit": 50
}

RBAC:

  • Pre-dataroom gating filters unless unlocked
  • Buyers see only their own requests + published requests

get_request

Full detail on a single request, including linked answers and thread.

Parameters:

Name Type Required Description
project_id string no Defaults to session-bound project
request_id string yes Entry ID or ref (e.g., "FIN-042")
include_thread bool no Include entry_events thread (default: false)

Returns:

{
  "entry_id": "ent_req001",
  "ref": "FIN-042",
  "title": "Audited financials FY2024",
  "body": "Please provide audited financial statements for fiscal year 2024, including...",
  "status": "answered",
  "priority": "high",
  "due_date": "2026-03-15",
  "assigned_to": [{"user_id": "usr_001", "name": "John Doe"}],
  "routing_chain": [
    {"actor": "IB Analyst", "action": "forwarded", "ts": 1709000000},
    {"actor": "CFO", "action": "forwarded", "ts": 1709100000},
    {"actor": "John Doe", "action": "assigned", "ts": 1709150000}
  ],
  "answers": [
    {"answer_id": "ent_ans001", "status": "submitted", "submitted_at": 1709200000}
  ],
  "thread": [
    {"ts": 1709000000, "actor": "Jane Smith", "action": "created", "channel": "web"},
    {"ts": 1709100000, "actor": "Bob CFO", "action": "message", "body": "John, can you handle this?"}
  ]
}

RBAC:

  • Routing chain visible to IB only
  • Thread visible based on workstream access
  • Pre-dataroom gating applies

get_my_tasks

Personal task inbox — requests assigned to the actor.

Parameters:

Name Type Required Description
project_id string no Defaults to session-bound project
status string[] no Filter by status
workstream string no Filter by workstream

Returns:

{
  "tasks": [
    {
      "entry_id": "ent_req001",
      "ref": "FIN-042",
      "title": "Audited financials FY2024",
      "workstream": "finance",
      "status": "assigned",
      "priority": "high",
      "due_date": "2026-03-15",
      "return_to": {"user_id": "usr_cfo", "name": "Bob CFO"},
      "days_until_due": 15
    }
  ],
  "overdue_count": 2,
  "due_today_count": 1
}

RBAC: Always filtered to assignee_id = actor_id.


4.4 Answer Tools

list_answers

List answers in a workstream or linked to a request.

Parameters:

Name Type Required Description
project_id string no Defaults to session-bound project
workstream string no Filter by workstream
request_id string no Filter by linked request
status string[] no Filter by status
limit int no Max results
offset int no Pagination offset

Returns:

{
  "answers": [
    {
      "entry_id": "ent_ans001",
      "title": "FY2024 Audited Financials",
      "status": "published",
      "linked_requests": ["FIN-042", "FIN-043"],
      "file_count": 3,
      "published_at": 1709300000
    }
  ],
  "total": 23,
  "offset": 0
}

RBAC:

  • Pre-dataroom gating: only status=published unless unlocked
  • Buyers: only published answers they're entitled to see

get_answer

Full answer detail including files and linked requests.

Parameters:

Name Type Required Description
project_id string no Defaults to session-bound project
answer_id string yes Answer entry ID
include_files bool no Include file metadata (default: true)

Returns:

{
  "entry_id": "ent_ans001",
  "title": "FY2024 Audited Financials",
  "body": "Attached are the audited financial statements for FY2024...",
  "status": "published",
  "linked_requests": [
    {"ref": "FIN-042", "title": "Audited financials FY2024", "confirmed": true},
    {"ref": "FIN-043", "title": "Annual financial report", "confirmed": true, "ai_score": 0.89}
  ],
  "files": [
    {"object_id": "obj_001", "filename": "AuditedFinancials_FY2024.pdf", "size": 2456789, "mime": "application/pdf"},
    {"object_id": "obj_002", "filename": "BalanceSheet.xlsx", "size": 156789, "mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}
  ],
  "vetting": {
    "vetted_by": "Jane Smith",
    "vetted_at": 1709250000,
    "notes": "Verified against source documents"
  },
  "published_at": 1709300000
}

RBAC:

  • Pre-dataroom gating applies
  • Vetting details visible to IB only

4.5 Routing Tools (Write — Human Confirmation Required)

suggest_routing

AI suggests how to route a request through the organization.

Parameters:

Name Type Required Description
project_id string no Defaults to session-bound project
request_id string yes Request to route
analysis string no AI's reasoning (stored for audit)

Returns:

{
  "suggestion_id": "sug_001",
  "request_ref": "FIN-042",
  "suggested_route": [
    {"user_id": "usr_cfo", "name": "Bob CFO", "reason": "CFO oversight required for audit materials"},
    {"user_id": "usr_acct", "name": "John Accountant", "reason": "Primary preparer of financial statements"}
  ],
  "confidence": 0.87,
  "status": "pending_confirmation",
  "confirmation_url": "https://dealspace.com/confirm/sug_001"
}

RBAC:

  • Requires write:routing scope
  • Suggestion is not applied — human must confirm via web UI
  • Logged to audit table with AI analysis

State: Creates routing_suggestion entry, status pending. No changes to request until human clicks confirm.


suggest_assignment

AI suggests who should handle a request based on workload and expertise.

Parameters:

Name Type Required Description
project_id string no Defaults to session-bound project
request_id string yes Request to assign
analysis string no AI's reasoning

Returns:

{
  "suggestion_id": "sug_002",
  "request_ref": "LEG-007",
  "suggested_assignee": {
    "user_id": "usr_legal01",
    "name": "Sarah Lawyer",
    "reason": "Handled similar IP requests, current workload: 3 open tasks"
  },
  "alternatives": [
    {"user_id": "usr_legal02", "name": "Tom Counsel", "reason": "Available but less experience with IP"}
  ],
  "status": "pending_confirmation"
}

RBAC: Same as suggest_routing — write scope, human confirmation required.


4.6 Matching Tools

find_matching_answers

AI finds existing answers that might satisfy a new buyer request.

Parameters:

Name Type Required Description
project_id string no Defaults to session-bound project
request_id string yes The new buyer request
threshold float no Minimum similarity score (default: 0.72)

Returns:

{
  "request_ref": "BUY-015",
  "request_title": "Provide employee headcount by department",
  "matches": [
    {
      "answer_id": "ent_ans042",
      "answer_title": "HR Overview - Headcount Analysis",
      "similarity": 0.89,
      "match_reason": "Contains departmental headcount breakdown",
      "status": "published"
    },
    {
      "answer_id": "ent_ans038",
      "answer_title": "Organizational Structure",
      "similarity": 0.74,
      "match_reason": "Includes department hierarchy with team sizes",
      "status": "published"
    }
  ],
  "recommendation": "High confidence match found. Suggest linking BUY-015 to answer ent_ans042."
}

RBAC:

  • Only searches published answers (pre-dataroom never matched against buyer requests)
  • Results filtered by buyer's workstream access

AI suggests linking an answer to requests it satisfies.

Parameters:

Name Type Required Description
project_id string no Defaults to session-bound project
answer_id string yes Answer to link
request_ids string[] yes Requests to link
analysis string no AI's reasoning

Returns:

{
  "suggestion_id": "sug_003",
  "answer_id": "ent_ans042",
  "proposed_links": [
    {"request_ref": "BUY-015", "confidence": 0.89},
    {"request_ref": "BUY-022", "confidence": 0.76}
  ],
  "status": "pending_confirmation"
}

RBAC: Requires write:routing scope, human confirmation.


4.7 Event Tools

list_events

List workflow events for an entry or project.

Parameters:

Name Type Required Description
project_id string no Defaults to session-bound project
entry_id string no Filter to specific entry
actor_id string no Filter by actor
action string[] no Filter by action type
since int no Unix timestamp, events after
limit int no Max results

Returns:

{
  "events": [
    {
      "event_id": "evt_001",
      "entry_id": "ent_req001",
      "entry_ref": "FIN-042",
      "actor": {"user_id": "usr_001", "name": "John Doe"},
      "channel": "web",
      "action": "submitted",
      "ts": 1709200000,
      "summary": "Submitted answer with 3 files"
    }
  ],
  "total": 156
}

RBAC:

  • Events filtered by entry access
  • Internal routing events visible to IB only

4.8 System Tools

get_version

Server version and capabilities.

Parameters: None

Returns:

{
  "server": "dealspace",
  "version": "1.0.0",
  "protocol": "2025-06-18",
  "capabilities": {
    "tools": true,
    "resources": false,
    "prompts": false
  }
}

5. System Prompt Injection

When an MCP session is established, the server injects context about the deal into the AI's system prompt.

5.1 Injected Context

You are assisting with the M&A deal: "{project_name}"

Deal Stage: {stage}
Your Role: {actor_role}
Accessible Workstreams: {workstream_list}

Deal Context:
- {total_requests} total requests across all workstreams
- {completion_pct}% overall completion
- {pending_count} requests pending response
- {overdue_count} requests overdue

Your Capabilities:
- Read request status, answers, and workflow history
- Query across workstreams you have access to
- Suggest routing and assignments (requires human confirmation)
- Find matching answers for new requests

Restrictions:
- You cannot see pre-dataroom content unless explicitly unlocked
- You cannot execute state changes — only suggest them
- You cannot see content from other projects
- All actions are logged for audit

{role_specific_guidance}

5.2 Role-Specific Guidance

For IB roles:

As an IB team member, you can:
- View full routing chains and internal discussions
- See pending/draft answers before publication
- Suggest routing through seller organization
- Monitor deal progress across all accessible workstreams

Focus on: vetting quality, timeline management, buyer request triage

For Seller roles:

As a seller team member, you can:
- View requests assigned to your organization
- Track answer submission and vetting status
- See feedback from IB on rejected answers

Focus on: timely responses, complete documentation, addressing feedback

For Buyer roles:

As a buyer, you can:
- View published data room content
- Submit new requests
- Track status of your requests

You cannot see: internal routing, unpublished answers, other buyers' requests

5.3 Dynamic Context Updates

On each tool call, the server may inject additional context:

[Context Update]
Request FIN-042 status changed: assigned → answered
Answer submitted by John Doe at 2026-03-15 14:30 UTC

6. Autonomous vs Human Confirmation

6.1 Fully Autonomous (No Confirmation)

Action Rationale
List projects/workstreams/requests Read-only, RBAC enforced
Get entry details Read-only
Query answers Read-only
Find matching answers Computation only, no state change
List events Read-only audit trail
Get version System info

6.2 Requires Human Confirmation

Action Confirmation Flow
suggest_routing Creates pending suggestion → user clicks confirm in web UI
suggest_assignment Creates pending suggestion → user clicks confirm
suggest_answer_link Creates pending link → IB confirms before answer broadcast

Why human confirmation:

  • Routing affects people's task inboxes
  • Assignment creates work obligations
  • Answer links trigger notifications to buyers
  • Mistakes in M&A have real consequences

6.3 Never Allowed via MCP

Action Why
Create/delete entries Too consequential for AI autonomy
Publish answers Legal/compliance implications
Grant/revoke access Security-critical
Modify files Document integrity
Send notifications External communication

These actions require direct web UI interaction with full audit context.


7. Go Implementation Sketch

7.1 Package Structure

dealspace/
  mcp/
    server.go       // HTTP handler, JSON-RPC routing
    tools.go        // Tool implementations
    context.go      // AccessContext, scope handling
    inject.go       // System prompt generation
    types.go        // MCP-specific types

7.2 Core Types

// mcp/context.go
package mcp

type AccessContext struct {
    ActorID   string
    ProjectID string   // session-bound or per-call
    Scopes    []string // from OAuth token
    Role      string   // resolved from access table
}

func (c *AccessContext) HasScope(scope string) bool {
    for _, s := range c.Scopes {
        if s == scope {
            return true
        }
    }
    return false
}

func (c *AccessContext) CanAccessPreDataroom() bool {
    return c.HasScope("unlock:pre_dataroom")
}

7.3 Server Handler

// mcp/server.go
package mcp

import (
    "encoding/json"
    "net/http"
    
    "dealspace/lib"
)

type MCPServer struct {
    db *lib.DB
}

func (s *MCPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Extract OAuth token
    token := extractBearerToken(r)
    if token == "" {
        writeError(w, "unauthorized", "missing access token")
        return
    }
    
    // Resolve session
    session, err := s.db.GetOAuthSession(token)
    if err != nil {
        writeError(w, "unauthorized", "invalid token")
        return
    }
    
    // Build access context
    ctx := AccessContext{
        ActorID:   session.UserID,
        ProjectID: session.BoundProjectID, // may be empty
        Scopes:    session.Scopes,
    }
    
    // Parse JSON-RPC request
    var req JSONRPCRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, "parse_error", err.Error())
        return
    }
    
    // Route to handler
    result, err := s.dispatch(ctx, req)
    if err != nil {
        writeJSONRPCError(w, req.ID, err)
        return
    }
    
    writeJSONRPCResult(w, req.ID, result)
}

func (s *MCPServer) dispatch(ctx AccessContext, req JSONRPCRequest) (any, error) {
    switch req.Method {
    case "tools/list":
        return s.listTools(ctx)
    case "tools/call":
        return s.callTool(ctx, req.Params)
    case "initialize":
        return s.initialize(ctx, req.Params)
    default:
        return nil, &MCPError{Code: -32601, Message: "method not found"}
    }
}

7.4 Tool Implementations

// mcp/tools.go
package mcp

import (
    "dealspace/lib"
)

func (s *MCPServer) callTool(ctx AccessContext, params json.RawMessage) (any, error) {
    var call ToolCall
    if err := json.Unmarshal(params, &call); err != nil {
        return nil, err
    }
    
    switch call.Name {
    case "list_projects":
        return s.listProjects(ctx)
    case "set_project":
        return s.setProject(ctx, call.Arguments)
    case "list_requests":
        return s.listRequests(ctx, call.Arguments)
    case "get_request":
        return s.getRequest(ctx, call.Arguments)
    case "get_my_tasks":
        return s.getMyTasks(ctx, call.Arguments)
    case "list_answers":
        return s.listAnswers(ctx, call.Arguments)
    case "suggest_routing":
        return s.suggestRouting(ctx, call.Arguments)
    case "find_matching_answers":
        return s.findMatchingAnswers(ctx, call.Arguments)
    // ... other tools
    default:
        return nil, &MCPError{Code: -32602, Message: "unknown tool: " + call.Name}
    }
}

func (s *MCPServer) listRequests(ctx AccessContext, args json.RawMessage) (*ListRequestsResult, error) {
    var a ListRequestsArgs
    if err := json.Unmarshal(args, &a); err != nil {
        return nil, err
    }
    
    // Resolve project
    projectID := a.ProjectID
    if projectID == "" {
        projectID = ctx.ProjectID
    }
    if projectID == "" {
        return nil, &MCPError{Code: -32602, Message: "project_id required"}
    }
    
    // Build filter with gating
    filter := lib.EntryFilter{
        ProjectID:  projectID,
        Type:       "request",
        Workstream: a.Workstream,
    }
    
    // Pre-dataroom gating
    if !ctx.CanAccessPreDataroom() {
        filter.Stage = "dataroom"
    }
    
    if len(a.Status) > 0 {
        filter.Status = a.Status
    }
    
    // RBAC-enforced query
    entries, err := lib.EntryRead(ctx.ActorID, projectID, filter)
    if err != nil {
        return nil, err
    }
    
    // Transform to result
    result := &ListRequestsResult{
        Requests: make([]RequestSummary, 0, len(entries)),
    }
    for _, e := range entries {
        result.Requests = append(result.Requests, entryToRequestSummary(e))
    }
    
    return result, nil
}

func (s *MCPServer) suggestRouting(ctx AccessContext, args json.RawMessage) (*RoutingSuggestion, error) {
    // Require write:routing scope
    if !ctx.HasScope("write:routing") {
        return nil, &MCPError{Code: -32600, Message: "write:routing scope required"}
    }
    
    var a SuggestRoutingArgs
    if err := json.Unmarshal(args, &a); err != nil {
        return nil, err
    }
    
    // Verify access to request
    request, err := lib.EntryReadOne(ctx.ActorID, a.ProjectID, a.RequestID)
    if err != nil {
        return nil, err
    }
    
    // Create suggestion (does NOT apply it)
    suggestion := &RoutingSuggestion{
        SuggestionID: lib.NewID("sug"),
        RequestRef:   request.Data.Ref,
        Status:       "pending_confirmation",
        Analysis:     a.Analysis,
        CreatedAt:    time.Now().Unix(),
        CreatedBy:    ctx.ActorID,
    }
    
    // AI routing logic would go here
    suggestion.SuggestedRoute = s.computeRoutingSuggestion(ctx, request)
    
    // Persist suggestion
    if err := lib.SaveRoutingSuggestion(suggestion); err != nil {
        return nil, err
    }
    
    // Audit log
    lib.AuditLog(ctx.ActorID, a.ProjectID, "routing_suggestion_created", suggestion.SuggestionID, a.Analysis)
    
    return suggestion, nil
}

7.5 System Prompt Injection

// mcp/inject.go
package mcp

import (
    "fmt"
    "strings"
    
    "dealspace/lib"
)

func (s *MCPServer) generateSystemPrompt(ctx AccessContext) string {
    project, _ := lib.GetProject(ctx.ActorID, ctx.ProjectID)
    if project == nil {
        return basePrompt
    }
    
    // Get role
    role, _ := lib.GetUserRole(ctx.ActorID, ctx.ProjectID)
    
    // Get accessible workstreams
    workstreams, _ := lib.GetAccessibleWorkstreams(ctx.ActorID, ctx.ProjectID)
    
    // Get stats
    stats, _ := lib.GetProjectStats(ctx.ActorID, ctx.ProjectID)
    
    prompt := fmt.Sprintf(`You are assisting with the M&A deal: "%s"

Deal Stage: %s
Your Role: %s
Accessible Workstreams: %s

Deal Context:
- %d total requests across all workstreams
- %.1f%% overall completion
- %d requests pending response
- %d requests overdue

Your Capabilities:
- Read request status, answers, and workflow history
- Query across workstreams you have access to
- Suggest routing and assignments (requires human confirmation)
- Find matching answers for new requests

Restrictions:
- You cannot see pre-dataroom content unless explicitly unlocked
- You cannot execute state changes — only suggest them
- You cannot see content from other projects
- All actions are logged for audit

%s`,
        project.Name,
        project.Stage,
        role,
        strings.Join(workstreams, ", "),
        stats.TotalRequests,
        stats.CompletionPct,
        stats.PendingCount,
        stats.OverdueCount,
        roleGuidance(role),
    )
    
    return prompt
}

func roleGuidance(role string) string {
    switch {
    case strings.HasPrefix(role, "ib_"):
        return ibGuidance
    case strings.HasPrefix(role, "seller_"):
        return sellerGuidance
    case strings.HasPrefix(role, "buyer_"):
        return buyerGuidance
    default:
        return ""
    }
}

const ibGuidance = `As an IB team member, you can:
- View full routing chains and internal discussions
- See pending/draft answers before publication
- Suggest routing through seller organization
- Monitor deal progress across all accessible workstreams

Focus on: vetting quality, timeline management, buyer request triage`

const sellerGuidance = `As a seller team member, you can:
- View requests assigned to your organization
- Track answer submission and vetting status
- See feedback from IB on rejected answers

Focus on: timely responses, complete documentation, addressing feedback`

const buyerGuidance = `As a buyer, you can:
- View published data room content
- Submit new requests
- Track status of your requests

You cannot see: internal routing, unpublished answers, other buyers' requests`

7.6 Matching Implementation

// mcp/matching.go
package mcp

import (
    "dealspace/lib"
)

func (s *MCPServer) findMatchingAnswers(ctx AccessContext, args json.RawMessage) (*MatchingResult, error) {
    var a FindMatchingArgs
    if err := json.Unmarshal(args, &a); err != nil {
        return nil, err
    }
    
    // Get the request
    request, err := lib.EntryReadOne(ctx.ActorID, a.ProjectID, a.RequestID)
    if err != nil {
        return nil, err
    }
    
    // Get request embedding
    requestEmbed, err := lib.GetOrCreateEmbedding(request.EntryID, request.Data.Title + " " + request.Data.Body)
    if err != nil {
        return nil, err
    }
    
    threshold := a.Threshold
    if threshold == 0 {
        threshold = 0.72 // default from SPEC.md
    }
    
    // Find published answers in same workstream
    filter := lib.EntryFilter{
        ProjectID:  a.ProjectID,
        Type:       "answer",
        Stage:      "dataroom",
        Status:     []string{"published"},
        Workstream: request.Workstream,
    }
    
    answers, err := lib.EntryRead(ctx.ActorID, a.ProjectID, filter)
    if err != nil {
        return nil, err
    }
    
    // Score each answer
    var matches []AnswerMatch
    for _, ans := range answers {
        ansEmbed, err := lib.GetOrCreateEmbedding(ans.EntryID, ans.Data.Title + " " + ans.Data.Body)
        if err != nil {
            continue
        }
        
        score := lib.CosineSimilarity(requestEmbed, ansEmbed)
        if score >= threshold {
            matches = append(matches, AnswerMatch{
                AnswerID:    ans.EntryID,
                AnswerTitle: ans.Data.Title,
                Similarity:  score,
                MatchReason: generateMatchReason(request, ans, score),
                Status:      ans.Data.Status,
            })
        }
    }
    
    // Sort by similarity descending
    sort.Slice(matches, func(i, j int) bool {
        return matches[i].Similarity > matches[j].Similarity
    })
    
    result := &MatchingResult{
        RequestRef:   request.Data.Ref,
        RequestTitle: request.Data.Title,
        Matches:      matches,
    }
    
    if len(matches) > 0 && matches[0].Similarity >= 0.85 {
        result.Recommendation = fmt.Sprintf(
            "High confidence match found. Suggest linking %s to answer %s.",
            request.Data.Ref, matches[0].AnswerID,
        )
    }
    
    return result, nil
}

8. Security Considerations

8.1 Audit Trail

Every MCP call logged:

lib.AuditLog(ctx.ActorID, projectID, "mcp_tool_call", map[string]any{
    "tool":       toolName,
    "args":       sanitizedArgs, // no raw content
    "result_count": len(results),
    "ip":         r.RemoteAddr,
    "user_agent": r.UserAgent(),
})

8.2 Rate Limiting

  • Per-user: 100 requests/minute
  • Per-project: 1000 requests/minute
  • Embedding generation: 10/minute (expensive)

8.3 Data Minimization

  • Tool results include IDs and summaries, not full content blobs
  • File content never returned via MCP (only metadata + download URLs)
  • Thread bodies truncated to 1000 chars in list views

8.4 Token Lifetime

  • Access tokens: 1 hour
  • Refresh tokens: 7 days
  • unlock:pre_dataroom tokens: 15 minutes (forces re-consent)

9. Error Handling

9.1 Error Codes

Code Meaning
-32600 Invalid request (malformed JSON-RPC)
-32601 Method not found
-32602 Invalid params
-32603 Internal error
1001 Unauthorized (invalid/expired token)
1002 Forbidden (RBAC denied)
1003 Not found
1004 Scope required
1005 Rate limited

9.2 Error Response Format

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": 1002,
    "message": "Access denied to workstream: legal",
    "data": {
      "required_role": "ib_member or seller_member",
      "current_role": "buyer_member"
    }
  }
}

10. Testing

10.1 Manual Test (curl)

# Get token
TOKEN=$(curl -s -X POST https://dealspace.com/oauth/token \
  -d "grant_type=client_credentials&client_id=test&client_secret=test" \
  | jq -r .access_token)

# List projects
curl -X POST https://dealspace.com/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {"name": "list_projects", "arguments": {}}
  }'

10.2 Claude Desktop Integration

{
  "mcpServers": {
    "dealspace": {
      "url": "https://dealspace.com/mcp",
      "transport": {"type": "http"}
    }
  }
}

This document is the MCP specification. If implementation disagrees with this spec, the implementation is wrong.