dealspace/MCP-SPEC.md

1426 lines
36 KiB
Markdown

# 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`
```http
POST /mcp HTTP/1.1
Host: dealspace.com
Authorization: Bearer {access_token}
Content-Type: application/json
```
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:**
```json
{"name": "list_requests", "arguments": {"project_id": "proj_abc123", "workstream": "finance"}}
```
**Session-bound project (via `set_project`):**
```json
{"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
```go
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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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
---
#### `suggest_answer_link`
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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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
```go
// 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
```go
// 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
```go
// 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
```go
// 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
```go
// 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:
```go
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
```json
{
"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)
```bash
# 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
```json
{
"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.*