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:
- Actor ID — extracted from OAuth token (the authenticated user)
- 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:
- Extracts access token from
Authorizationheader - Looks up session → gets
actor_idand granted scopes - Creates
AccessContext{ActorID, ProjectID, Scopes} - Routes to tool handler with context
- 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:
- Default state:
unlock:pre_dataroomscope NOT in token - Tool behavior: Pre-dataroom entries filtered at query time
- Unlock flow: User re-authorizes with explicit scope consent
- 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=publishedunless 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:routingscope - 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
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:
{
"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_dataroomtokens: 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.