فهرست منبع

add mcp server

david 3 روز پیش
والد
کامیت
ef52eb1bbd

+ 612 - 0
CLIENT_GUIDE.md

@@ -11,6 +11,7 @@ This document provides the necessary information to implement a client for the A
 5. [GraphQL Operations](#graphql-operations)
 6. [Error Handling](#error-handling)
 7. [Subscriptions](#subscriptions)
+8. [MCP Server](#mcp-server)
 
 ---
 
@@ -1037,3 +1038,614 @@ curl -X POST https://api.example.com/query \
   -H "Authorization: Bearer YOUR_TOKEN" \
   -d '{"query": "query { users { id email } }"}'
 ```
+
+---
+
+## MCP Server
+
+The ARP server also exposes a **Model Context Protocol (MCP)** interface for AI agent integration. MCP allows AI assistants to discover and use the GraphQL API through a standardized tool interface.
+
+### MCP Endpoint
+
+- **Protocol**: HTTP with Server-Sent Events (SSE)
+- **Endpoint**: `/mcp` (SSE connection endpoint)
+- **Message Endpoint**: `/message` (for sending JSON-RPC messages)
+- **Protocol Version**: `2024-11-05`
+
+### MCP Authentication
+
+All MCP operations require authentication. The authentication flow is:
+
+1. **Obtain JWT Token**: First, authenticate via the GraphQL `login` mutation to get a JWT token
+2. **Include Token in SSE Connection**: Pass the `Authorization` header when establishing the SSE connection to `/mcp`
+3. **Session Maintains Auth**: The user context is stored in the session, so subsequent `/message` requests inherit authentication
+
+#### Authentication Header
+
+Include the JWT token when connecting to the MCP endpoint:
+
+```http
+GET /mcp HTTP/1.1
+Host: api.example.com
+Accept: text/event-stream
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+```
+
+#### Authenticated Connection Flow
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ 1. Client: POST /query { login(email, password) }              │
+│    ↓ Response: { token: "jwt..." }                              │
+│                                                                 │
+│ 2. Client: GET /mcp Authorization: Bearer jwt...                │
+│    ↓ Server validates token, creates session with user context  │
+│    ↓ Server sends: event: endpoint, data: /message?sessionId=X  │
+│                                                                 │
+│ 3. Client: POST /message?sessionId=X { method: "tools/call" }   │
+│    ↓ Server uses stored user context for authorization          │
+│    ↓ Response sent via SSE event stream                         │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+#### JavaScript Example: Authenticated MCP Client
+
+```javascript
+// Step 1: Login to get JWT token
+async function login(email, password) {
+  const response = await fetch('/query', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      query: 'mutation Login($email: String!, $password: String!) { login(email: $email, password: $password) { token } }',
+      variables: { email, password }
+    })
+  });
+  const data = await response.json();
+  return data.data.login.token;
+}
+
+// Step 2: Establish authenticated SSE connection
+function createMCPConnection(token) {
+  const eventSource = new EventSource('/mcp', {
+    headers: {
+      'Authorization': `Bearer ${token}`
+    }
+  });
+  
+  // Note: EventSource doesn't support headers natively
+  // Use a library like 'event-source-polyfill' or fetch-based SSE
+  
+  return eventSource;
+}
+
+// Alternative: Using fetch-based SSE with authentication
+async function createAuthenticatedMCP(token) {
+  const response = await fetch('/mcp', {
+    headers: {
+      'Authorization': `Bearer ${token}`,
+      'Accept': 'text/event-stream'
+    }
+  });
+  
+  const reader = response.body.getReader();
+  const decoder = new TextDecoder();
+  
+  return { reader, decoder };
+}
+```
+
+#### Using event-source-polyfill Library
+
+```javascript
+import { EventSourcePolyfill } from 'event-source-polyfill';
+
+async function connectMCP() {
+  // First, login to get token
+  const token = await login('user@example.com', 'password');
+  
+  // Create authenticated SSE connection
+  const eventSource = new EventSourcePolyfill('/mcp', {
+    headers: {
+      'Authorization': `Bearer ${token}`
+    }
+  });
+  
+  let messageEndpoint = null;
+  
+  eventSource.addEventListener('endpoint', (event) => {
+    messageEndpoint = event.data;
+    console.log('Message endpoint:', messageEndpoint);
+    
+    // Initialize the MCP session
+    initializeMCP(messageEndpoint);
+  });
+  
+  eventSource.addEventListener('message', (event) => {
+    const response = JSON.parse(event.data);
+    console.log('MCP response:', response);
+  });
+  
+  eventSource.onerror = (error) => {
+    console.error('SSE error:', error);
+  };
+}
+
+async function initializeMCP(messageEndpoint) {
+  await fetch(messageEndpoint, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      jsonrpc: '2.0',
+      id: 1,
+      method: 'initialize',
+      params: {
+        protocolVersion: '2024-11-05',
+        capabilities: {},
+        clientInfo: { name: 'my-client', version: '1.0.0' }
+      }
+    })
+  });
+}
+```
+
+#### Authentication Errors
+
+If authentication fails, you'll receive an error when trying to execute operations:
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 1,
+  "result": {
+    "content": [
+      { "type": "text", "text": "unauthorized: authentication required" }
+    ],
+    "isError": true
+  }
+}
+```
+
+#### Subscription Authentication
+
+MCP resource subscriptions (for real-time updates) also require authentication. The subscription endpoints check for user context before allowing subscriptions:
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 8,
+  "method": "resources/subscribe",
+  "params": {
+    "uri": "graphql://subscription/taskCreated"
+  }
+}
+```
+
+If not authenticated:
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 8,
+  "error": {
+    "code": -32603,
+    "message": "Authentication required for subscriptions"
+  }
+}
+```
+
+### Available Tools
+
+The MCP server exposes three tools:
+
+| Tool | Description |
+|------|-------------|
+| `introspect` | Discover the GraphQL schema - types, fields, queries, mutations |
+| `query` | Execute GraphQL queries (read operations) |
+| `mutate` | Execute GraphQL mutations (create/update/delete operations) |
+
+### Connection Flow
+
+1. **Establish SSE Connection**: Connect to `/mcp` endpoint
+2. **Receive Endpoint URL**: Server sends an `endpoint` event with the message URL
+3. **Initialize**: Send an `initialize` request via the message endpoint
+4. **Call Tools**: Use `tools/call` to execute introspect, query, or mutate
+
+### MCP Protocol Messages
+
+#### Initialize Request
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 1,
+  "method": "initialize",
+  "params": {
+    "protocolVersion": "2024-11-05",
+    "capabilities": {},
+    "clientInfo": {
+      "name": "my-client",
+      "version": "1.0.0"
+    }
+  }
+}
+```
+
+#### Initialize Response
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 1,
+  "result": {
+    "protocolVersion": "2024-11-05",
+    "capabilities": {
+      "tools": { "listChanged": false }
+    },
+    "serverInfo": {
+      "name": "ARP MCP Server",
+      "version": "1.0.0"
+    },
+    "instructions": "Use the introspect tool to discover the GraphQL schema..."
+  }
+}
+```
+
+#### List Tools Request
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 2,
+  "method": "tools/list"
+}
+```
+
+#### List Tools Response
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 2,
+  "result": {
+    "tools": [
+      {
+        "name": "introspect",
+        "description": "Get GraphQL schema information...",
+        "inputSchema": {
+          "type": "object",
+          "properties": {
+            "typeName": {
+              "type": "string",
+              "description": "Optional - specific type to introspect"
+            }
+          }
+        }
+      },
+      {
+        "name": "query",
+        "description": "Execute GraphQL queries...",
+        "inputSchema": {
+          "type": "object",
+          "properties": {
+            "query": { "type": "string" },
+            "variables": { "type": "object" }
+          },
+          "required": ["query"]
+        }
+      },
+      {
+        "name": "mutate",
+        "description": "Execute GraphQL mutations...",
+        "inputSchema": {
+          "type": "object",
+          "properties": {
+            "mutation": { "type": "string" },
+            "variables": { "type": "object" }
+          },
+          "required": ["mutation"]
+        }
+      }
+    ]
+  }
+}
+```
+
+### Tool Usage Examples
+
+#### Introspect Tool
+
+Get full schema overview:
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 3,
+  "method": "tools/call",
+  "params": {
+    "name": "introspect",
+    "arguments": {}
+  }
+}
+```
+
+Get specific type information:
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 3,
+  "method": "tools/call",
+  "params": {
+    "name": "introspect",
+    "arguments": {
+      "typeName": "User"
+    }
+  }
+}
+```
+
+#### Query Tool
+
+Execute a GraphQL query:
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 4,
+  "method": "tools/call",
+  "params": {
+    "name": "query",
+    "arguments": {
+      "query": "query { users { id email roles { name } } }"
+    }
+  }
+}
+```
+
+With variables:
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 5,
+  "method": "tools/call",
+  "params": {
+    "name": "query",
+    "arguments": {
+      "query": "query User($id: ID!) { user(id: $id) { id email } }",
+      "variables": { "id": "1" }
+    }
+  }
+}
+```
+
+#### Mutate Tool
+
+Execute a GraphQL mutation:
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 6,
+  "method": "tools/call",
+  "params": {
+    "name": "mutate",
+    "arguments": {
+      "mutation": "mutation CreateUser($input: NewUser!) { createUser(input: $input) { id email } }",
+      "variables": {
+        "input": {
+          "email": "newuser@example.com",
+          "password": "securepassword",
+          "roles": []
+        }
+      }
+    }
+  }
+}
+```
+
+### Authentication
+
+The MCP server inherits authentication from the SSE connection. If you need authenticated operations:
+
+1. First obtain a JWT token via the GraphQL `login` mutation
+2. Include the token when establishing the SSE connection or in request headers
+
+### Example: JavaScript MCP Client
+
+```javascript
+// Establish SSE connection
+const eventSource = new EventSource('/mcp');
+
+eventSource.addEventListener('endpoint', (event) => {
+  const messageUrl = event.data;
+  console.log('Message endpoint:', messageUrl);
+  
+  // Initialize the connection
+  fetch(messageUrl, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      jsonrpc: '2.0',
+      id: 1,
+      method: 'initialize',
+      params: {
+        protocolVersion: '2024-11-05',
+        capabilities: {},
+        clientInfo: { name: 'js-client', version: '1.0.0' }
+      }
+    })
+  });
+});
+
+eventSource.addEventListener('message', (event) => {
+  const response = JSON.parse(event.data);
+  console.log('Received:', response);
+});
+
+// Call a tool
+async function callTool(name, arguments) {
+  const response = await fetch('/message?sessionId=YOUR_SESSION_ID', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      jsonrpc: '2.0',
+      id: Date.now(),
+      method: 'tools/call',
+      params: { name, arguments }
+    })
+  });
+  return response.json();
+}
+
+// Usage
+await callTool('query', { query: '{ users { id email } }' });
+```
+
+### MCP Use Cases
+
+| Use Case | Tool | Example |
+|----------|------|---------|
+| Discover API structure | `introspect` | Get schema before making queries |
+| Read user data | `query` | Fetch users, tasks, messages |
+| Create new records | `mutate` | Create users, tasks, notes |
+| Update existing records | `mutate` | Update task status, user info |
+| Delete records | `mutate` | Remove tasks, notes, users |
+
+### MCP Resources (Subscriptions)
+
+The MCP server also supports **resources** for real-time subscriptions. Resources allow AI agents to subscribe to GraphQL subscription events through the MCP protocol.
+
+#### Available Resources
+
+| Resource URI | Description |
+|--------------|-------------|
+| `graphql://subscription/taskCreated` | Task creation events (received by assignee) |
+| `graphql://subscription/taskUpdated` | Task update events (received by assignee) |
+| `graphql://subscription/taskDeleted` | Task deletion events (received by assignee) |
+| `graphql://subscription/messageAdded` | New message events (received by receivers) |
+
+#### List Resources Request
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 7,
+  "method": "resources/list"
+}
+```
+
+#### List Resources Response
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 7,
+  "result": {
+    "resources": [
+      {
+        "uri": "graphql://subscription/taskCreated",
+        "name": "taskCreated",
+        "description": "Subscribe to task creation events...",
+        "mimeType": "application/json"
+      }
+    ]
+  }
+}
+```
+
+#### Subscribe to Resource
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 8,
+  "method": "resources/subscribe",
+  "params": {
+    "uri": "graphql://subscription/taskCreated"
+  }
+}
+```
+
+#### Subscribe Response
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 8,
+  "result": {
+    "subscribed": true,
+    "uri": "graphql://subscription/taskCreated"
+  }
+}
+```
+
+#### Receive Resource Updates
+
+After subscribing, you'll receive notifications via the SSE connection:
+
+```json
+{
+  "jsonrpc": "2.0",
+  "method": "notifications/resources/updated",
+  "params": {
+    "uri": "graphql://subscription/taskCreated",
+    "contents": {
+      "uri": "graphql://subscription/taskCreated",
+      "mimeType": "application/json",
+      "text": "{\"id\":\"5\",\"title\":\"New Task\",\"assigneeId\":\"2\",...}"
+    }
+  }
+}
+```
+
+#### Unsubscribe
+
+```json
+{
+  "jsonrpc": "2.0",
+  "id": 9,
+  "method": "resources/unsubscribe",
+  "params": {
+    "uri": "graphql://subscription/taskCreated"
+  }
+}
+```
+
+#### Subscription Example
+
+```javascript
+// Subscribe to task creation events
+async function subscribeToTasks(sessionId) {
+  const response = await fetch(`/message?sessionId=${sessionId}`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      jsonrpc: '2.0',
+      id: 1,
+      method: 'resources/subscribe',
+      params: { uri: 'graphql://subscription/taskCreated' }
+    })
+  });
+  return response.json();
+}
+
+// Listen for notifications on the SSE connection
+eventSource.addEventListener('message', (event) => {
+  const data = JSON.parse(event.data);
+  if (data.method === 'notifications/resources/updated') {
+    const task = JSON.parse(data.params.contents.text);
+    console.log('New task:', task);
+  }
+});
+```
+
+### MCP Best Practices
+
+1. **Introspect First**: Always call `introspect` to understand the schema before making queries
+2. **Handle Errors**: Check for `isError: true` in tool results
+3. **Use Variables**: Pass variables separately for better readability and security
+4. **Session Management**: Maintain the SSE connection for the duration of your session
+5. **Reconnect on Disconnect**: Implement reconnection logic for long-running sessions
+6. **Use Resources for Real-time**: Subscribe to resources for real-time updates instead of polling

+ 1 - 1
graph/schema.resolvers.go

@@ -873,7 +873,7 @@ func (r *mutationResolver) CreateMessage(ctx context.Context, input model.NewMes
 	graphqlMessage := convertMessage(message)
 	r.PublishMessageEvent(graphqlMessage, notifyReceiverIDs)
 
-	logging.LogMutation(ctx, "CREATE", "MESSAGE", fmt.Sprintf("id=%d", message.ID))
+	logging.LogMutation(ctx, "CREATE", "MESSAGE", fmt.Sprintf("id=%d content=%s", message.ID, message.Content))
 	return graphqlMessage, nil
 }
 

+ 8 - 25
init_prod.sql

@@ -24,9 +24,7 @@ CREATE TABLE IF NOT EXISTS roles (
 CREATE TABLE IF NOT EXISTS role_permissions (
     role_id INTEGER NOT NULL,
     permission_id INTEGER NOT NULL,
-    PRIMARY KEY (role_id, permission_id),
-    FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
-    FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
+    PRIMARY KEY (role_id, permission_id)
 );
 
 -- Users table
@@ -42,9 +40,7 @@ CREATE TABLE IF NOT EXISTS users (
 CREATE TABLE IF NOT EXISTS user_roles (
     user_id INTEGER NOT NULL,
     role_id INTEGER NOT NULL,
-    PRIMARY KEY (user_id, role_id),
-    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-    FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
+    PRIMARY KEY (user_id, role_id)
 );
 
 -- Task Statuses table
@@ -63,17 +59,14 @@ CREATE TABLE IF NOT EXISTS services (
     description TEXT,
     created_by_id INTEGER,
     created_at DATETIME,
-    updated_at DATETIME,
-    FOREIGN KEY (created_by_id) REFERENCES users(id)
+    updated_at DATETIME
 );
 
 -- Service Participants join table
 CREATE TABLE IF NOT EXISTS service_participants (
     service_id INTEGER NOT NULL,
     user_id INTEGER NOT NULL,
-    PRIMARY KEY (service_id, user_id),
-    FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE,
-    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+    PRIMARY KEY (service_id, user_id)
 );
 
 -- Tasks table
@@ -89,12 +82,7 @@ CREATE TABLE IF NOT EXISTS tasks (
     due_date DATETIME,
     priority TEXT,
     created_at DATETIME,
-    updated_at DATETIME,
-    FOREIGN KEY (service_id) REFERENCES services(id),
-    FOREIGN KEY (created_by_id) REFERENCES users(id),
-    FOREIGN KEY (updated_by_id) REFERENCES users(id),
-    FOREIGN KEY (assignee_id) REFERENCES users(id),
-    FOREIGN KEY (status_id) REFERENCES task_statuses(id) ON UPDATE CASCADE ON DELETE SET NULL
+    updated_at DATETIME
 );
 
 -- Notes table
@@ -105,9 +93,7 @@ CREATE TABLE IF NOT EXISTS notes (
     user_id INTEGER,
     service_id INTEGER,
     created_at DATETIME,
-    updated_at DATETIME,
-    FOREIGN KEY (user_id) REFERENCES users(id),
-    FOREIGN KEY (service_id) REFERENCES services(id)
+    updated_at DATETIME
 );
 
 -- Channels table
@@ -122,9 +108,7 @@ CREATE TABLE IF NOT EXISTS channels (
 CREATE TABLE IF NOT EXISTS conversation_participants (
     channel_id INTEGER NOT NULL,
     user_id INTEGER NOT NULL,
-    PRIMARY KEY (channel_id, user_id),
-    FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
-    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+    PRIMARY KEY (channel_id, user_id)
 );
 
 -- Messages table
@@ -134,8 +118,7 @@ CREATE TABLE IF NOT EXISTS messages (
     content TEXT NOT NULL,
     sent_at DATETIME,
     created_at DATETIME,
-    updated_at DATETIME,
-    FOREIGN KEY (sender_id) REFERENCES users(id)
+    updated_at DATETIME
 );
 
 -- ============================================

+ 352 - 0
mcp/handler.go

@@ -0,0 +1,352 @@
+package mcp
+
+import (
+	"context"
+	"encoding/json"
+
+	"gogs.dmsc.dev/arp/auth"
+	"gogs.dmsc.dev/arp/mcp/tools"
+)
+
+// handleToolsList returns the list of available tools
+func (s *Server) handleToolsList(ctx context.Context, req *JSONRPCRequest) *JSONRPCResponse {
+	toolList := []Tool{
+		{
+			Name:        "introspect",
+			Description: "Get GraphQL schema information - types, fields, queries, mutations. Use this to discover the API structure before making queries or mutations.",
+			InputSchema: InputSchema{
+				Type:                 "object",
+				AdditionalProperties: false,
+				Properties: map[string]Property{
+					"typeName": {
+						Type:        "string",
+						Description: "Optional - specific type to introspect (e.g., 'Query', 'Mutation', 'User', 'Task'). If omitted, returns full schema overview.",
+					},
+				},
+			},
+		},
+		{
+			Name:        "query",
+			Description: "Execute GraphQL queries (read operations). Use for fetching data from the API. The query must be a valid GraphQL query string.",
+			InputSchema: InputSchema{
+				Type:                 "object",
+				AdditionalProperties: false,
+				Properties: map[string]Property{
+					"query": {
+						Type:        "string",
+						Description: "GraphQL query string (e.g., 'query { users { id email } }')",
+					},
+					"variables": {
+						Type:        "object",
+						Description: "Optional query variables as key-value pairs",
+					},
+				},
+				Required: []string{"query"},
+			},
+		},
+		{
+			Name:        "mutate",
+			Description: "Execute GraphQL mutations (create/update/delete operations). Use for modifying data in the API. The mutation must be a valid GraphQL mutation string.",
+			InputSchema: InputSchema{
+				Type:                 "object",
+				AdditionalProperties: false,
+				Properties: map[string]Property{
+					"mutation": {
+						Type:        "string",
+						Description: "GraphQL mutation string (e.g., 'mutation { createUser(input: {email: \"test@example.com\", password: \"pass\", roles: []}) { id } }')",
+					},
+					"variables": {
+						Type:        "object",
+						Description: "Optional mutation variables as key-value pairs",
+					},
+				},
+				Required: []string{"mutation"},
+			},
+		},
+	}
+
+	return &JSONRPCResponse{
+		JSONRPC: "2.0",
+		ID:      req.ID,
+		Result:  ListToolsResult{Tools: toolList},
+	}
+}
+
+// handleToolsCall executes a tool call
+func (s *Server) handleToolsCall(ctx context.Context, req *JSONRPCRequest) *JSONRPCResponse {
+	var params CallToolParams
+	if req.Params != nil {
+		if err := json.Unmarshal(req.Params, &params); err != nil {
+			return &JSONRPCResponse{
+				JSONRPC: "2.0",
+				ID:      req.ID,
+				Error:   ErrInvalidParams,
+			}
+		}
+	}
+
+	var result tools.CallToolResult
+	var err error
+
+	switch params.Name {
+	case "introspect":
+		result, err = tools.Introspect(ctx, s.schema, params.Arguments)
+	case "query":
+		result, err = tools.Query(ctx, s.resolver, s.schema, params.Arguments)
+	case "mutate":
+		result, err = tools.Mutate(ctx, s.resolver, s.schema, params.Arguments)
+	default:
+		return &JSONRPCResponse{
+			JSONRPC: "2.0",
+			ID:      req.ID,
+			Error:   ErrMethodNotFound,
+		}
+	}
+
+	if err != nil {
+		return &JSONRPCResponse{
+			JSONRPC: "2.0",
+			ID:      req.ID,
+			Result: tools.CallToolResult{
+				Content: []tools.ContentBlock{
+					{Type: "text", Text: err.Error()},
+				},
+				IsError: true,
+			},
+		}
+	}
+
+	return &JSONRPCResponse{
+		JSONRPC: "2.0",
+		ID:      req.ID,
+		Result:  result,
+	}
+}
+
+// handleResourcesList returns the list of available subscription resources
+func (s *Server) handleResourcesList(ctx context.Context, req *JSONRPCRequest) *JSONRPCResponse {
+	resources := []Resource{
+		{
+			URI:         "graphql://subscription/taskCreated",
+			Name:        "taskCreated",
+			Description: "Subscribe to task creation events. Receives Task objects when new tasks are created and assigned to you.",
+			MimeType:    "application/json",
+		},
+		{
+			URI:         "graphql://subscription/taskUpdated",
+			Name:        "taskUpdated",
+			Description: "Subscribe to task update events. Receives Task objects when tasks assigned to you are updated.",
+			MimeType:    "application/json",
+		},
+		{
+			URI:         "graphql://subscription/taskDeleted",
+			Name:        "taskDeleted",
+			Description: "Subscribe to task deletion events. Receives Task objects when tasks assigned to you are deleted.",
+			MimeType:    "application/json",
+		},
+		{
+			URI:         "graphql://subscription/messageAdded",
+			Name:        "messageAdded",
+			Description: "Subscribe to new message events. Receives Message objects when messages are sent to you.",
+			MimeType:    "application/json",
+		},
+	}
+
+	return &JSONRPCResponse{
+		JSONRPC: "2.0",
+		ID:      req.ID,
+		Result:  ListResourcesResult{Resources: resources},
+	}
+}
+
+// handleResourcesRead returns current state of a resource (for subscriptions, this is a description)
+func (s *Server) handleResourcesRead(ctx context.Context, req *JSONRPCRequest) *JSONRPCResponse {
+	var params ReadResourceParams
+	if req.Params != nil {
+		if err := json.Unmarshal(req.Params, &params); err != nil {
+			return &JSONRPCResponse{
+				JSONRPC: "2.0",
+				ID:      req.ID,
+				Error:   ErrInvalidParams,
+			}
+		}
+	}
+
+	// For subscriptions, reading returns a description
+	description := "This is a subscription resource. Use resources/subscribe to receive real-time updates."
+
+	return &JSONRPCResponse{
+		JSONRPC: "2.0",
+		ID:      req.ID,
+		Result: ReadResourceResult{
+			Contents: []ResourceContents{
+				{
+					URI:      params.URI,
+					MimeType: "text/plain",
+					Text:     description,
+				},
+			},
+		},
+	}
+}
+
+// handleResourcesSubscribe starts a subscription for real-time updates
+func (s *Server) handleResourcesSubscribe(ctx context.Context, req *JSONRPCRequest, session *Session) *JSONRPCResponse {
+	var params SubscribeParams
+	if req.Params != nil {
+		if err := json.Unmarshal(req.Params, &params); err != nil {
+			return &JSONRPCResponse{
+				JSONRPC: "2.0",
+				ID:      req.ID,
+				Error:   ErrInvalidParams,
+			}
+		}
+	}
+
+	// Check authentication
+	user, err := auth.CurrentUser(ctx)
+	if err != nil {
+		return &JSONRPCResponse{
+			JSONRPC: "2.0",
+			ID:      req.ID,
+			Error:   &RPCError{Code: -32603, Message: "Authentication required for subscriptions"},
+		}
+	}
+
+	// Create cancellable context for this subscription
+	subCtx, cancel := context.WithCancel(context.Background())
+
+	// Store the cancel function
+	session.SubsMu.Lock()
+	session.Subscriptions[params.URI] = cancel
+	session.SubsMu.Unlock()
+
+	// Start the subscription based on URI
+	go s.runSubscription(subCtx, params.URI, user.ID, session)
+
+	return &JSONRPCResponse{
+		JSONRPC: "2.0",
+		ID:      req.ID,
+		Result:  map[string]interface{}{"subscribed": true, "uri": params.URI},
+	}
+}
+
+// handleResourcesUnsubscribe stops a subscription
+func (s *Server) handleResourcesUnsubscribe(ctx context.Context, req *JSONRPCRequest, session *Session) *JSONRPCResponse {
+	var params UnsubscribeParams
+	if req.Params != nil {
+		if err := json.Unmarshal(req.Params, &params); err != nil {
+			return &JSONRPCResponse{
+				JSONRPC: "2.0",
+				ID:      req.ID,
+				Error:   ErrInvalidParams,
+			}
+		}
+	}
+
+	session.SubsMu.Lock()
+	if cancel, ok := session.Subscriptions[params.URI]; ok {
+		cancel()
+		delete(session.Subscriptions, params.URI)
+	}
+	session.SubsMu.Unlock()
+
+	return &JSONRPCResponse{
+		JSONRPC: "2.0",
+		ID:      req.ID,
+		Result:  map[string]interface{}{"unsubscribed": true, "uri": params.URI},
+	}
+}
+
+// runSubscription handles the actual subscription event streaming
+func (s *Server) runSubscription(ctx context.Context, uri string, userID uint, session *Session) {
+	switch uri {
+	case "graphql://subscription/taskCreated":
+		s.streamTaskEvents(ctx, userID, session, "created")
+	case "graphql://subscription/taskUpdated":
+		s.streamTaskEvents(ctx, userID, session, "updated")
+	case "graphql://subscription/taskDeleted":
+		s.streamTaskEvents(ctx, userID, session, "deleted")
+	case "graphql://subscription/messageAdded":
+		s.streamMessageEvents(ctx, userID, session)
+	}
+}
+
+// streamTaskEvents streams task events to the session
+func (s *Server) streamTaskEvents(ctx context.Context, userID uint, session *Session, eventType string) {
+	eventChan := s.resolver.SubscribeToTasks(userID)
+
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-session.Done:
+			return
+		case event, ok := <-eventChan:
+			if !ok {
+				return
+			}
+			if event.EventType == eventType && event.Task != nil {
+				notification := CreateResourceNotification(
+					"graphql://subscription/task"+capitalize(eventType),
+					event.Task,
+				)
+				s.sendNotification(session, notification)
+			}
+		}
+	}
+}
+
+// streamMessageEvents streams message events to the session
+func (s *Server) streamMessageEvents(ctx context.Context, userID uint, session *Session) {
+	eventChan := s.resolver.SubscribeToMessages(userID)
+
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-session.Done:
+			return
+		case event, ok := <-eventChan:
+			if !ok {
+				return
+			}
+			// Check if user is a receiver
+			isReceiver := false
+			for _, receiverID := range event.ReceiverIDs {
+				if receiverID == userID {
+					isReceiver = true
+					break
+				}
+			}
+			if isReceiver && event.Message != nil {
+				notification := CreateResourceNotification(
+					"graphql://subscription/messageAdded",
+					event.Message,
+				)
+				s.sendNotification(session, notification)
+			}
+		}
+	}
+}
+
+// sendNotification sends a JSON-RPC notification to the session
+func (s *Server) sendNotification(session *Session, notification *JSONRPCNotification) {
+	notifBytes, err := json.Marshal(notification)
+	if err != nil {
+		return
+	}
+	select {
+	case session.Events <- notifBytes:
+	default:
+		// Channel full, skip
+	}
+}
+
+// capitalize helper
+func capitalize(s string) string {
+	if len(s) == 0 {
+		return s
+	}
+	return string(s[0]-32) + s[1:]
+}

+ 638 - 0
mcp/integration_test.go

@@ -0,0 +1,638 @@
+package mcp
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/bradleyjkemp/cupaloy/v2"
+	"github.com/vektah/gqlparser/v2/ast"
+	"gogs.dmsc.dev/arp/auth"
+	"gogs.dmsc.dev/arp/graph"
+	"gogs.dmsc.dev/arp/graph/testutil"
+	"gorm.io/gorm"
+)
+
+var snapshotter = cupaloy.New(cupaloy.SnapshotSubdirectory("testdata/snapshots"))
+
+// MCPTestClient wraps the MCP server for testing
+type MCPTestClient struct {
+	server  *Server
+	db      *gorm.DB
+	schema  *ast.Schema
+	session *Session
+	user    *auth.UserContext
+}
+
+// IDTracker tracks entity IDs created during tests
+type IDTracker struct {
+	Permissions  map[string]string
+	Roles        map[string]string
+	Users        map[string]string
+	TaskStatuses map[string]string
+	Services     map[string]string
+	Tasks        map[string]string
+	Notes        map[string]string
+	Messages     []string
+}
+
+func NewIDTracker() *IDTracker {
+	return &IDTracker{
+		Permissions:  make(map[string]string),
+		Roles:        make(map[string]string),
+		Users:        make(map[string]string),
+		TaskStatuses: make(map[string]string),
+		Services:     make(map[string]string),
+		Tasks:        make(map[string]string),
+		Notes:        make(map[string]string),
+		Messages:     make([]string, 0),
+	}
+}
+
+// setupMCPTestClient creates a test client with bootstrapped database
+func setupMCPTestClient(t *testing.T) (*MCPTestClient, *IDTracker) {
+	db, err := testutil.SetupAndBootstrapTestDB()
+	if err != nil {
+		t.Fatalf("Failed to setup test database: %v", err)
+	}
+
+	resolver := graph.NewResolver(db)
+	schema := graph.NewExecutableSchema(graph.Config{Resolvers: resolver})
+	astSchema := schema.Schema()
+
+	server := NewServer(resolver, astSchema)
+
+	adminUser := &auth.UserContext{
+		ID:    1,
+		Email: "admin@example.com",
+		Roles: []auth.RoleClaim{{ID: 1, Name: "admin"}},
+		Permissions: []string{
+			"user:read", "user:write",
+			"task:read", "task:write",
+			"service:read", "service:write",
+			"note:read", "note:write",
+		},
+	}
+
+	session := &Session{
+		ID:            "test-session-id",
+		User:          adminUser,
+		Events:        make(chan []byte, 100),
+		Done:          make(chan struct{}),
+		Subscriptions: make(map[string]context.CancelFunc),
+	}
+
+	tracker := NewIDTracker()
+	tracker.Users["admin@example.com"] = "1"
+
+	var perms []struct {
+		ID   uint
+		Code string
+	}
+	db.Table("permissions").Find(&perms)
+	for _, perm := range perms {
+		tracker.Permissions[perm.Code] = fmt.Sprintf("%d", perm.ID)
+	}
+
+	var roles []struct {
+		ID   uint
+		Name string
+	}
+	db.Table("roles").Find(&roles)
+	for _, role := range roles {
+		tracker.Roles[role.Name] = fmt.Sprintf("%d", role.ID)
+	}
+
+	var statuses []struct {
+		ID   uint
+		Code string
+	}
+	db.Table("task_statuses").Find(&statuses)
+	for _, status := range statuses {
+		tracker.TaskStatuses[status.Code] = fmt.Sprintf("%d", status.ID)
+	}
+
+	return &MCPTestClient{
+		server:  server,
+		db:      db,
+		schema:  astSchema,
+		session: session,
+		user:    adminUser,
+	}, tracker
+}
+
+func (tc *MCPTestClient) callTool(ctx context.Context, name string, args map[string]interface{}) *JSONRPCResponse {
+	argsJSON, _ := json.Marshal(args)
+	params := json.RawMessage(fmt.Sprintf(`{"name": "%s", "arguments": %s}`, name, string(argsJSON)))
+
+	req := &JSONRPCRequest{
+		JSONRPC: "2.0",
+		ID:      fmt.Sprintf("test-%d", time.Now().UnixNano()),
+		Method:  "tools/call",
+		Params:  params,
+	}
+
+	// Inject user into context
+	ctxWithUser := auth.WithUser(ctx, tc.user)
+	return tc.server.handleRequest(ctxWithUser, req, tc.session)
+}
+
+func (tc *MCPTestClient) callMethod(ctx context.Context, method string, params json.RawMessage) *JSONRPCResponse {
+	req := &JSONRPCRequest{
+		JSONRPC: "2.0",
+		ID:      fmt.Sprintf("test-%d", time.Now().UnixNano()),
+		Method:  method,
+		Params:  params,
+	}
+
+	// Inject user into context
+	ctxWithUser := auth.WithUser(ctx, tc.user)
+	return tc.server.handleRequest(ctxWithUser, req, tc.session)
+}
+
+func (tc *MCPTestClient) subscribeToResource(ctx context.Context, uri string) *JSONRPCResponse {
+	params := json.RawMessage(fmt.Sprintf(`{"uri": "%s"}`, uri))
+	return tc.callMethod(ctx, "resources/subscribe", params)
+}
+
+func (tc *MCPTestClient) unsubscribeFromResource(ctx context.Context, uri string) *JSONRPCResponse {
+	params := json.RawMessage(fmt.Sprintf(`{"uri": "%s"}`, uri))
+	return tc.callMethod(ctx, "resources/unsubscribe", params)
+}
+
+func normalizeJSON(jsonStr string) string {
+	var data interface{}
+	if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
+		return jsonStr
+	}
+	normalizeData(data)
+	bytes, _ := json.MarshalIndent(data, "", "  ")
+	return string(bytes)
+}
+
+func normalizeData(data interface{}) {
+	switch v := data.(type) {
+	case map[string]interface{}:
+		delete(v, "id")
+		delete(v, "ID")
+		delete(v, "createdAt")
+		delete(v, "updatedAt")
+		delete(v, "sentAt")
+		delete(v, "createdByID")
+		delete(v, "userId")
+		delete(v, "serviceId")
+		delete(v, "statusId")
+		delete(v, "assigneeId")
+		delete(v, "conversationId")
+		delete(v, "senderId")
+		delete(v, "password") // Remove password hashes
+		for key, val := range v {
+			// Normalize embedded JSON strings in "text" field
+			if key == "text" {
+				if strVal, ok := val.(string); ok {
+					var embedded interface{}
+					if err := json.Unmarshal([]byte(strVal), &embedded); err == nil {
+						normalizeData(embedded)
+						if embeddedBytes, err := json.Marshal(embedded); err == nil {
+							v[key] = string(embeddedBytes)
+							continue
+						}
+					}
+				}
+			}
+			normalizeData(val)
+		}
+	case []interface{}:
+		for _, item := range v {
+			normalizeData(item)
+		}
+	}
+}
+
+func snapshotResult(t *testing.T, name string, response *JSONRPCResponse) {
+	jsonBytes, _ := json.MarshalIndent(response, "", "  ")
+	normalized := normalizeJSON(string(jsonBytes))
+	snapshotter.SnapshotT(t, name, normalized)
+}
+
+// TestMCP_Initialize tests the initialize method
+func TestMCP_Initialize(t *testing.T) {
+	tc, _ := setupMCPTestClient(t)
+	ctx := context.Background()
+
+	params := json.RawMessage(`{"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test-client", "version": "1.0.0"}}`)
+	response := tc.callMethod(ctx, "initialize", params)
+
+	if response.Error != nil {
+		t.Fatalf("Initialize failed: %v", response.Error)
+	}
+
+	result, ok := response.Result.(InitializeResult)
+	if !ok {
+		t.Fatalf("Expected InitializeResult, got %T", response.Result)
+	}
+
+	if result.ProtocolVersion != ProtocolVersion {
+		t.Errorf("Expected protocol version %s, got %s", ProtocolVersion, result.ProtocolVersion)
+	}
+
+	if result.Capabilities.Tools == nil {
+		t.Error("Expected tools capability to be present")
+	}
+
+	if result.Capabilities.Resources == nil {
+		t.Error("Expected resources capability to be present")
+	}
+
+	if !result.Capabilities.Resources.Subscribe {
+		t.Error("Expected resources.subscribe to be true")
+	}
+
+	snapshotResult(t, "initialize", response)
+}
+
+// TestMCP_ToolsList tests the tools/list method
+func TestMCP_ToolsList(t *testing.T) {
+	tc, _ := setupMCPTestClient(t)
+	ctx := context.Background()
+
+	response := tc.callMethod(ctx, "tools/list", nil)
+
+	if response.Error != nil {
+		t.Fatalf("tools/list failed: %v", response.Error)
+	}
+
+	snapshotResult(t, "tools_list", response)
+}
+
+// TestMCP_ResourcesList tests the resources/list method
+func TestMCP_ResourcesList(t *testing.T) {
+	tc, _ := setupMCPTestClient(t)
+	ctx := context.Background()
+
+	response := tc.callMethod(ctx, "resources/list", nil)
+
+	if response.Error != nil {
+		t.Fatalf("resources/list failed: %v", response.Error)
+	}
+
+	snapshotResult(t, "resources_list", response)
+}
+
+// TestMCP_Introspect tests the introspect tool
+func TestMCP_Introspect(t *testing.T) {
+	tc, _ := setupMCPTestClient(t)
+	ctx := context.Background()
+
+	t.Run("FullSchema", func(t *testing.T) {
+		response := tc.callTool(ctx, "introspect", map[string]interface{}{})
+
+		if response.Error != nil {
+			t.Fatalf("introspect failed: %v", response.Error)
+		}
+
+		// Verify the response contains expected content (skip snapshot due to non-deterministic ordering)
+		jsonBytes, _ := json.Marshal(response.Result)
+		var result map[string]interface{}
+		if err := json.Unmarshal(jsonBytes, &result); err != nil {
+			t.Fatalf("Failed to unmarshal result: %v", err)
+		}
+
+		content, ok := result["content"].([]interface{})
+		if !ok || len(content) == 0 {
+			t.Fatal("Expected content array with at least one item")
+		}
+
+		text, ok := content[0].(map[string]interface{})["text"].(string)
+		if !ok {
+			t.Fatal("Expected text field in content")
+		}
+
+		// Verify key sections are present
+		expectedSections := []string{"Query Type", "Mutation Type", "Subscription Type", "Object Types", "Input Types"}
+		for _, section := range expectedSections {
+			if !strings.Contains(text, section) {
+				t.Errorf("Expected section '%s' in introspection result", section)
+			}
+		}
+	})
+
+	t.Run("QueryType", func(t *testing.T) {
+		response := tc.callTool(ctx, "introspect", map[string]interface{}{
+			"typeName": "Query",
+		})
+
+		if response.Error != nil {
+			t.Fatalf("introspect Query failed: %v", response.Error)
+		}
+
+		snapshotResult(t, "introspect_query", response)
+	})
+
+	t.Run("UserType", func(t *testing.T) {
+		response := tc.callTool(ctx, "introspect", map[string]interface{}{
+			"typeName": "User",
+		})
+
+		if response.Error != nil {
+			t.Fatalf("introspect User failed: %v", response.Error)
+		}
+
+		snapshotResult(t, "introspect_user", response)
+	})
+}
+
+// TestMCP_Query tests the query tool
+func TestMCP_Query(t *testing.T) {
+	tc, _ := setupMCPTestClient(t)
+	ctx := context.Background()
+
+	t.Run("Users", func(t *testing.T) {
+		response := tc.callTool(ctx, "query", map[string]interface{}{
+			"query": "query { users { email roles { name } } }",
+		})
+
+		if response.Error != nil {
+			t.Fatalf("query users failed: %v", response.Error)
+		}
+
+		snapshotResult(t, "query_users", response)
+	})
+
+	t.Run("Tasks", func(t *testing.T) {
+		response := tc.callTool(ctx, "query", map[string]interface{}{
+			"query": "query { tasks { title content priority } }",
+		})
+
+		if response.Error != nil {
+			t.Fatalf("query tasks failed: %v", response.Error)
+		}
+
+		snapshotResult(t, "query_tasks", response)
+	})
+
+	t.Run("Services", func(t *testing.T) {
+		response := tc.callTool(ctx, "query", map[string]interface{}{
+			"query": "query { services { name description } }",
+		})
+
+		if response.Error != nil {
+			t.Fatalf("query services failed: %v", response.Error)
+		}
+
+		snapshotResult(t, "query_services", response)
+	})
+
+	t.Run("Roles", func(t *testing.T) {
+		response := tc.callTool(ctx, "query", map[string]interface{}{
+			"query": "query { roles { name description permissions { code } } }",
+		})
+
+		if response.Error != nil {
+			t.Fatalf("query roles failed: %v", response.Error)
+		}
+
+		snapshotResult(t, "query_roles", response)
+	})
+
+	t.Run("InvalidQuery", func(t *testing.T) {
+		response := tc.callTool(ctx, "query", map[string]interface{}{
+			"query": "query { nonexistent { id } }",
+		})
+
+		if response.Error != nil {
+			t.Fatalf("query failed: %v", response.Error)
+		}
+
+		// The result is a CallToolResult with isError=true
+		jsonBytes, _ := json.Marshal(response.Result)
+		var result map[string]interface{}
+		if err := json.Unmarshal(jsonBytes, &result); err != nil {
+			t.Fatalf("Failed to unmarshal result: %v", err)
+		}
+
+		if isError, ok := result["isError"].(bool); !ok || !isError {
+			t.Error("Expected isError to be true for invalid query")
+		}
+
+		snapshotResult(t, "query_invalid", response)
+	})
+}
+
+// TestMCP_Mutate tests the mutate tool
+func TestMCP_Mutate(t *testing.T) {
+	tc, tracker := setupMCPTestClient(t)
+	ctx := context.Background()
+
+	t.Run("CreateUser", func(t *testing.T) {
+		response := tc.callTool(ctx, "mutate", map[string]interface{}{
+			"mutation": fmt.Sprintf(`mutation { createUser(input: {email: "newuser@example.com", password: "password123", roles: ["%s"]}) { email } }`, tracker.Roles["admin"]),
+		})
+
+		if response.Error != nil {
+			t.Fatalf("createUser failed: %v", response.Error)
+		}
+
+		snapshotResult(t, "mutate_create_user", response)
+	})
+
+	t.Run("CreateTask", func(t *testing.T) {
+		response := tc.callTool(ctx, "mutate", map[string]interface{}{
+			"mutation": fmt.Sprintf(`mutation { createTask(input: {title: "Test Task", content: "Test content", createdById: "%s", statusId: "%s", priority: "high"}) { title content priority } }`, tracker.Users["admin@example.com"], tracker.TaskStatuses["open"]),
+		})
+
+		if response.Error != nil {
+			t.Fatalf("createTask failed: %v", response.Error)
+		}
+
+		snapshotResult(t, "mutate_create_task", response)
+	})
+
+	t.Run("CreateNote", func(t *testing.T) {
+		response := tc.callTool(ctx, "mutate", map[string]interface{}{
+			"mutation": fmt.Sprintf(`mutation { createNote(input: {title: "Test Note", content: "Note content", userId: "%s"}) { title content } }`, tracker.Users["admin@example.com"]),
+		})
+
+		if response.Error != nil {
+			t.Fatalf("createNote failed: %v", response.Error)
+		}
+
+		snapshotResult(t, "mutate_create_note", response)
+	})
+}
+
+// TestMCP_Resources tests resource operations
+func TestMCP_Resources(t *testing.T) {
+	tc, _ := setupMCPTestClient(t)
+	ctx := context.Background()
+
+	t.Run("Read", func(t *testing.T) {
+		params := json.RawMessage(`{"uri": "graphql://subscription/taskCreated"}`)
+		response := tc.callMethod(ctx, "resources/read", params)
+
+		if response.Error != nil {
+			t.Fatalf("resources/read failed: %v", response.Error)
+		}
+
+		snapshotResult(t, "resources_read", response)
+	})
+
+	t.Run("Subscribe", func(t *testing.T) {
+		response := tc.subscribeToResource(ctx, "graphql://subscription/taskCreated")
+
+		if response.Error != nil {
+			t.Fatalf("resources/subscribe failed: %v", response.Error)
+		}
+
+		// Verify subscription was registered
+		tc.session.SubsMu.RLock()
+		_, ok := tc.session.Subscriptions["graphql://subscription/taskCreated"]
+		tc.session.SubsMu.RUnlock()
+
+		if !ok {
+			t.Error("Expected subscription to be registered in session")
+		}
+
+		snapshotResult(t, "resources_subscribe", response)
+	})
+
+	t.Run("Unsubscribe", func(t *testing.T) {
+		// First subscribe
+		tc.subscribeToResource(ctx, "graphql://subscription/taskUpdated")
+
+		// Then unsubscribe
+		response := tc.unsubscribeFromResource(ctx, "graphql://subscription/taskUpdated")
+
+		if response.Error != nil {
+			t.Fatalf("resources/unsubscribe failed: %v", response.Error)
+		}
+
+		// Verify subscription was removed
+		tc.session.SubsMu.RLock()
+		_, ok := tc.session.Subscriptions["graphql://subscription/taskUpdated"]
+		tc.session.SubsMu.RUnlock()
+
+		if ok {
+			t.Error("Expected subscription to be removed from session")
+		}
+
+		snapshotResult(t, "resources_unsubscribe", response)
+	})
+}
+
+// TestMCP_SubscriptionNotifications tests that subscription notifications are sent
+func TestMCP_SubscriptionNotifications(t *testing.T) {
+	tc, tracker := setupMCPTestClient(t)
+	ctx := context.Background()
+
+	// Subscribe to taskCreated
+	_ = tc.subscribeToResource(ctx, "graphql://subscription/taskCreated")
+
+	// Create a task assigned to admin user (ID 1)
+	_ = tc.callTool(ctx, "mutate", map[string]interface{}{
+		"mutation": fmt.Sprintf(`mutation { createTask(input: {title: "Notification Test Task", content: "Testing notifications", createdById: "%s", assigneeId: "%s", statusId: "%s", priority: "medium"}) { title } }`, tracker.Users["admin@example.com"], tracker.Users["admin@example.com"], tracker.TaskStatuses["open"]),
+	})
+
+	// Wait for notification
+	select {
+	case event := <-tc.session.Events:
+		var notification JSONRPCNotification
+		if err := json.Unmarshal(event, &notification); err != nil {
+			t.Fatalf("Failed to unmarshal notification: %v", err)
+		}
+
+		// Verify it's a resource update notification
+		if notification.Method != "notifications/resources/updated" {
+			t.Errorf("Expected method 'notifications/resources/updated', got '%s'", notification.Method)
+		}
+
+		t.Logf("Received notification: %s", string(event))
+
+	case <-time.After(2 * time.Second):
+		t.Error("Timeout waiting for taskCreated notification")
+	}
+}
+
+// TestMCP_Ping tests the ping method
+func TestMCP_Ping(t *testing.T) {
+	tc, _ := setupMCPTestClient(t)
+	ctx := context.Background()
+
+	response := tc.callMethod(ctx, "ping", nil)
+
+	if response.Error != nil {
+		t.Fatalf("ping failed: %v", response.Error)
+	}
+
+	snapshotResult(t, "ping", response)
+}
+
+// TestMCP_Unauthenticated tests operations without authentication
+func TestMCP_Unauthenticated(t *testing.T) {
+	db, err := testutil.SetupAndBootstrapTestDB()
+	if err != nil {
+		t.Fatalf("Failed to setup test database: %v", err)
+	}
+
+	resolver := graph.NewResolver(db)
+	schema := graph.NewExecutableSchema(graph.Config{Resolvers: resolver})
+	astSchema := schema.Schema()
+
+	server := NewServer(resolver, astSchema)
+
+	// Create session without user
+	session := &Session{
+		ID:            "unauth-session",
+		User:          nil, // No user
+		Events:        make(chan []byte, 100),
+		Done:          make(chan struct{}),
+		Subscriptions: make(map[string]context.CancelFunc),
+	}
+
+	ctx := context.Background()
+
+	t.Run("SubscribeRequiresAuth", func(t *testing.T) {
+		params := json.RawMessage(`{"uri": "graphql://subscription/taskCreated"}`)
+		req := &JSONRPCRequest{
+			JSONRPC: "2.0",
+			ID:      "test-unauth",
+			Method:  "resources/subscribe",
+			Params:  params,
+		}
+
+		response := server.handleRequest(ctx, req, session)
+
+		if response.Error == nil {
+			t.Error("Expected error for unauthenticated subscribe")
+		}
+
+		if !strings.Contains(response.Error.Message, "Authentication required") {
+			t.Errorf("Expected authentication error, got: %s", response.Error.Message)
+		}
+	})
+
+	t.Run("QueryWithoutAuth", func(t *testing.T) {
+		// Query should work without auth (no permission checks in current implementation)
+		argsJSON, _ := json.Marshal(map[string]interface{}{
+			"query": "query { users { email } }",
+		})
+		params := json.RawMessage(fmt.Sprintf(`{"name": "query", "arguments": %s}`, string(argsJSON)))
+
+		req := &JSONRPCRequest{
+			JSONRPC: "2.0",
+			ID:      "test-unauth-query",
+			Method:  "tools/call",
+			Params:  params,
+		}
+
+		response := server.handleRequest(ctx, req, session)
+
+		// Query should succeed (no auth required for basic queries)
+		if response.Error != nil {
+			t.Logf("Query returned error (may be expected): %v", response.Error)
+		}
+	})
+}

+ 198 - 0
mcp/protocol.go

@@ -0,0 +1,198 @@
+package mcp
+
+import "encoding/json"
+
+// MCP Protocol constants
+const (
+	ProtocolVersion = "2024-11-05"
+	ContentType     = "application/json"
+)
+
+// JSON-RPC 2.0 types
+type JSONRPCRequest struct {
+	JSONRPC string          `json:"jsonrpc"`
+	ID      interface{}     `json:"id,omitempty"`
+	Method  string          `json:"method"`
+	Params  json.RawMessage `json:"params,omitempty"`
+}
+
+type JSONRPCResponse struct {
+	JSONRPC string      `json:"jsonrpc"`
+	ID      interface{} `json:"id,omitempty"`
+	Result  interface{} `json:"result,omitempty"`
+	Error   *RPCError   `json:"error,omitempty"`
+}
+
+type RPCError struct {
+	Code    int         `json:"code"`
+	Message string      `json:"message"`
+	Data    interface{} `json:"data,omitempty"`
+}
+
+// Standard JSON-RPC error codes
+var (
+	ErrParseError     = &RPCError{Code: -32700, Message: "Parse error"}
+	ErrInvalidRequest = &RPCError{Code: -32600, Message: "Invalid request"}
+	ErrMethodNotFound = &RPCError{Code: -32601, Message: "Method not found"}
+	ErrInvalidParams  = &RPCError{Code: -32602, Message: "Invalid params"}
+	ErrInternal       = &RPCError{Code: -32603, Message: "Internal error"}
+)
+
+// MCP specific types
+type InitializeParams struct {
+	ProtocolVersion string                 `json:"protocolVersion"`
+	Capabilities    ClientCapabilities     `json:"capabilities"`
+	ClientInfo      ImplementationInfo     `json:"clientInfo"`
+	Meta            map[string]interface{} `json:"_meta,omitempty"`
+}
+
+type InitializeResult struct {
+	ProtocolVersion string             `json:"protocolVersion"`
+	Capabilities    ServerCapabilities `json:"capabilities"`
+	ServerInfo      ImplementationInfo `json:"serverInfo"`
+	Instructions    string             `json:"instructions,omitempty"`
+}
+
+type ClientCapabilities struct {
+	Experimental map[string]interface{} `json:"experimental,omitempty"`
+	Roots        *RootsCapability       `json:"roots,omitempty"`
+	Sampling     *SamplingCapability    `json:"sampling,omitempty"`
+}
+
+type RootsCapability struct {
+	ListChanged bool `json:"listChanged,omitempty"`
+}
+
+type SamplingCapability struct{}
+
+type ServerCapabilities struct {
+	Experimental map[string]interface{} `json:"experimental,omitempty"`
+	Tools        *ToolsCapability       `json:"tools,omitempty"`
+	Resources    *ResourcesCapability   `json:"resources,omitempty"`
+}
+
+type ToolsCapability struct {
+	ListChanged bool `json:"listChanged,omitempty"`
+}
+
+type ResourcesCapability struct {
+	Subscribe   bool `json:"subscribe,omitempty"`
+	ListChanged bool `json:"listChanged,omitempty"`
+}
+
+type ImplementationInfo struct {
+	Name    string `json:"name"`
+	Version string `json:"version"`
+}
+
+// Tool types
+type Tool struct {
+	Name        string      `json:"name"`
+	Description string      `json:"description"`
+	InputSchema InputSchema `json:"inputSchema"`
+}
+
+type InputSchema struct {
+	Type                 string              `json:"type"`
+	Properties           map[string]Property `json:"properties,omitempty"`
+	Required             []string            `json:"required,omitempty"`
+	AdditionalProperties bool                `json:"additionalProperties"`
+}
+
+type Property struct {
+	Type        string `json:"type"`
+	Description string `json:"description,omitempty"`
+}
+
+type ListToolsResult struct {
+	Tools []Tool `json:"tools"`
+}
+
+type CallToolParams struct {
+	Name      string                 `json:"name"`
+	Arguments map[string]interface{} `json:"arguments,omitempty"`
+}
+
+type CallToolResult struct {
+	Content []ContentBlock `json:"content"`
+	IsError bool           `json:"isError,omitempty"`
+}
+
+type ContentBlock struct {
+	Type string `json:"type"`
+	Text string `json:"text"`
+}
+
+// Ping
+type PingResult struct{}
+
+// Resource types for subscriptions
+type Resource struct {
+	URI         string `json:"uri"`
+	Name        string `json:"name"`
+	Description string `json:"description,omitempty"`
+	MimeType    string `json:"mimeType,omitempty"`
+}
+
+type ListResourcesResult struct {
+	Resources []Resource `json:"resources"`
+}
+
+type ReadResourceParams struct {
+	URI string `json:"uri"`
+}
+
+type ReadResourceResult struct {
+	Contents []ResourceContents `json:"contents"`
+}
+
+type ResourceContents struct {
+	URI      string `json:"uri"`
+	MimeType string `json:"mimeType,omitempty"`
+	Text     string `json:"text,omitempty"`
+	Blob     string `json:"blob,omitempty"` // base64 encoded
+}
+
+// Subscription types
+type SubscribeParams struct {
+	URI string `json:"uri"`
+}
+
+type UnsubscribeParams struct {
+	URI string `json:"uri"`
+}
+
+// Resource update notification
+type ResourceUpdatedNotification struct {
+	URI      string           `json:"uri"`
+	Contents ResourceContents `json:"contents"`
+}
+
+// JSON-RPC Notification
+type JSONRPCNotification struct {
+	JSONRPC string      `json:"jsonrpc"`
+	Method  string      `json:"method"`
+	Params  interface{} `json:"params,omitempty"`
+}
+
+// CreateResourceNotification creates a JSON-RPC notification for resource updates
+func CreateResourceNotification(uri string, data interface{}) *JSONRPCNotification {
+	// Marshal the data to JSON
+	dataBytes, err := json.Marshal(data)
+	if err != nil {
+		dataBytes = []byte("{}")
+	}
+
+	return &JSONRPCNotification{
+		JSONRPC: "2.0",
+		Method:  "notifications/resources/updated",
+		Params: ResourceUpdatedNotification{
+			URI: uri,
+			Contents: ResourceContents{
+				URI:      uri,
+				MimeType: "application/json",
+				Text:     string(dataBytes),
+			},
+		},
+	}
+}

+ 256 - 0
mcp/server.go

@@ -0,0 +1,256 @@
+package mcp
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"sync"
+	"time"
+
+	"github.com/vektah/gqlparser/v2/ast"
+	"gogs.dmsc.dev/arp/auth"
+	"gogs.dmsc.dev/arp/graph"
+)
+
+// Server represents the MCP server
+type Server struct {
+	resolver *graph.Resolver
+	schema   *ast.Schema
+
+	// Session management for SSE
+	sessions   map[string]*Session
+	sessionsMu sync.RWMutex
+}
+
+// Session represents an SSE client session
+type Session struct {
+	ID            string
+	User          *auth.UserContext
+	Events        chan []byte
+	Done          chan struct{}
+	Subscriptions map[string]context.CancelFunc // URI -> cancel function
+	SubsMu        sync.RWMutex
+}
+
+// NewServer creates a new MCP server
+func NewServer(resolver *graph.Resolver, schema *ast.Schema) *Server {
+	return &Server{
+		resolver: resolver,
+		schema:   schema,
+		sessions: make(map[string]*Session),
+	}
+}
+
+// ServeHTTP handles MCP requests over SSE
+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// Set SSE headers
+	w.Header().Set("Content-Type", "text/event-stream")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+	w.Header().Set("Access-Control-Allow-Origin", "*")
+	w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
+
+	// Handle CORS preflight
+	if r.Method == "OPTIONS" {
+		w.WriteHeader(http.StatusOK)
+		return
+	}
+
+	// Get user context from request (set by auth middleware)
+	userCtx, _ := auth.CurrentUser(r.Context())
+
+	// Create session
+	sessionID := generateSessionID()
+	session := &Session{
+		ID:            sessionID,
+		User:          userCtx,
+		Events:        make(chan []byte, 100),
+		Done:          make(chan struct{}),
+		Subscriptions: make(map[string]context.CancelFunc),
+	}
+
+	// Register session
+	s.sessionsMu.Lock()
+	s.sessions[sessionID] = session
+	s.sessionsMu.Unlock()
+
+	defer func() {
+		s.sessionsMu.Lock()
+		delete(s.sessions, sessionID)
+		s.sessionsMu.Unlock()
+		close(session.Done)
+	}()
+
+	// Flush helper
+	flusher, ok := w.(http.Flusher)
+	if !ok {
+		http.Error(w, "SSE not supported", http.StatusInternalServerError)
+		return
+	}
+
+	// Send endpoint event
+	endpoint := fmt.Sprintf("/message?sessionId=%s", sessionID)
+	fmt.Fprintf(w, "event: endpoint\ndata: %s\n\n", endpoint)
+	flusher.Flush()
+
+	// Stream events
+	for {
+		select {
+		case event := <-session.Events:
+			fmt.Fprintf(w, "event: message\ndata: %s\n\n", string(event))
+			flusher.Flush()
+		case <-r.Context().Done():
+			return
+		case <-session.Done:
+			return
+		}
+	}
+}
+
+// HandleMessage handles incoming JSON-RPC messages
+func (s *Server) HandleMessage(w http.ResponseWriter, r *http.Request) {
+	// Set CORS headers
+	w.Header().Set("Content-Type", "application/json")
+	w.Header().Set("Access-Control-Allow-Origin", "*")
+	w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
+
+	// Handle CORS preflight
+	if r.Method == "OPTIONS" {
+		w.WriteHeader(http.StatusOK)
+		return
+	}
+
+	// Get session ID from query
+	sessionID := r.URL.Query().Get("sessionId")
+	if sessionID == "" {
+		s.writeError(w, nil, ErrInvalidParams)
+		return
+	}
+
+	// Get session
+	s.sessionsMu.RLock()
+	session, ok := s.sessions[sessionID]
+	s.sessionsMu.RUnlock()
+
+	if !ok {
+		s.writeError(w, nil, &RPCError{Code: -32001, Message: "Session not found"})
+		return
+	}
+
+	// Parse request
+	var req JSONRPCRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		s.writeError(w, nil, ErrParseError)
+		return
+	}
+
+	// Handle request
+	ctx := r.Context()
+	if session.User != nil {
+		ctx = auth.WithUser(ctx, session.User)
+	}
+
+	response := s.handleRequest(ctx, &req, session)
+
+	// Send response via SSE if there's a session
+	if response != nil && session != nil {
+		respBytes, _ := json.Marshal(response)
+		select {
+		case session.Events <- respBytes:
+		default:
+			// Channel full, skip
+		}
+	}
+
+	// Also write response to HTTP
+	w.WriteHeader(http.StatusAccepted)
+}
+
+// handleRequest processes a JSON-RPC request
+func (s *Server) handleRequest(ctx context.Context, req *JSONRPCRequest, session *Session) *JSONRPCResponse {
+	switch req.Method {
+	case "initialize":
+		return s.handleInitialize(ctx, req)
+	case "notifications/initialized":
+		// Notification, no response needed
+		return nil
+	case "ping":
+		return s.handlePing(ctx, req)
+	case "tools/list":
+		return s.handleToolsList(ctx, req)
+	case "tools/call":
+		return s.handleToolsCall(ctx, req)
+	case "resources/list":
+		return s.handleResourcesList(ctx, req)
+	case "resources/read":
+		return s.handleResourcesRead(ctx, req)
+	case "resources/subscribe":
+		return s.handleResourcesSubscribe(ctx, req, session)
+	case "resources/unsubscribe":
+		return s.handleResourcesUnsubscribe(ctx, req, session)
+	default:
+		return &JSONRPCResponse{
+			JSONRPC: "2.0",
+			ID:      req.ID,
+			Error:   ErrMethodNotFound,
+		}
+	}
+}
+
+// handleInitialize handles the initialize request
+func (s *Server) handleInitialize(ctx context.Context, req *JSONRPCRequest) *JSONRPCResponse {
+	var params InitializeParams
+	if req.Params != nil {
+		if err := json.Unmarshal(req.Params, &params); err != nil {
+			return &JSONRPCResponse{
+				JSONRPC: "2.0",
+				ID:      req.ID,
+				Error:   ErrInvalidParams,
+			}
+		}
+	}
+
+	result := InitializeResult{
+		ProtocolVersion: ProtocolVersion,
+		Capabilities: ServerCapabilities{
+			Tools:     &ToolsCapability{ListChanged: false},
+			Resources: &ResourcesCapability{Subscribe: true, ListChanged: false},
+		},
+		ServerInfo: ImplementationInfo{
+			Name:    "ARP MCP Server",
+			Version: "1.0.0",
+		},
+		Instructions: "Use the introspect tool to discover the GraphQL schema, query tool for read operations, mutate tool for write operations, and resources for real-time subscriptions.",
+	}
+
+	return &JSONRPCResponse{
+		JSONRPC: "2.0",
+		ID:      req.ID,
+		Result:  result,
+	}
+}
+
+// handlePing handles the ping request
+func (s *Server) handlePing(ctx context.Context, req *JSONRPCRequest) *JSONRPCResponse {
+	return &JSONRPCResponse{
+		JSONRPC: "2.0",
+		ID:      req.ID,
+		Result:  PingResult{},
+	}
+}
+
+// writeError writes a JSON-RPC error response
+func (s *Server) writeError(w http.ResponseWriter, id interface{}, err *RPCError) {
+	w.WriteHeader(http.StatusBadRequest)
+	json.NewEncoder(w).Encode(JSONRPCResponse{
+		JSONRPC: "2.0",
+		ID:      id,
+		Error:   err,
+	})
+}
+
+// generateSessionID generates a unique session ID
+func generateSessionID() string {
+	return fmt.Sprintf("%d", time.Now().UnixNano())
+}

+ 18 - 0
mcp/testdata/snapshots/TestMCP_Initialize

@@ -0,0 +1,18 @@
+initialize
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "capabilities": {
+      "resources": {
+        "subscribe": true
+      },
+      "tools": {}
+    },
+    "instructions": "Use the introspect tool to discover the GraphQL schema, query tool for read operations, mutate tool for write operations, and resources for real-time subscriptions.",
+    "protocolVersion": "2024-11-05",
+    "serverInfo": {
+      "name": "ARP MCP Server",
+      "version": "1.0.0"
+    }
+  }
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 6 - 0
mcp/testdata/snapshots/TestMCP_Introspect-FullSchema


+ 12 - 0
mcp/testdata/snapshots/TestMCP_Introspect-QueryType

@@ -0,0 +1,12 @@
+introspect_query
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "content": [
+      {
+        "text": "Type: Query (Object)\n\nFields:\n  users: [User!]\n  user(id: ID!): User\n  notes: [Note!]\n  note(id: ID!): Note\n  roles: [Role!]\n  role(id: ID!): Role\n  permissions: [Permission!]\n  permission(id: ID!): Permission\n  services: [Service!]\n  service(id: ID!): Service\n  tasks: [Task!]\n  task(id: ID!): Task\n  taskStatuses: [TaskStatus!]\n  taskStatus(id: ID!): TaskStatus\n  messages: [Message!]\n  message(id: ID!): Message\n  __schema: __Schema!\n  __type(name: String!): __Type\n",
+        "type": "text"
+      }
+    ]
+  }
+}

+ 12 - 0
mcp/testdata/snapshots/TestMCP_Introspect-UserType

@@ -0,0 +1,12 @@
+introspect_user
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "content": [
+      {
+        "text": "Type: User (Object)\n\nFields:\n  id: ID!\n  email: String!\n  password: String!\n  roles: [Role!]\n  createdAt: String!\n  updatedAt: String!\n",
+        "type": "text"
+      }
+    ]
+  }
+}

+ 13 - 0
mcp/testdata/snapshots/TestMCP_Mutate-CreateNote

@@ -0,0 +1,13 @@
+mutate_create_note
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "content": [
+      {
+        "text": "Mutation parse error: input:1:30: Field \"NewNote.serviceId\" of required type \"ID!\" was not provided.\n",
+        "type": "text"
+      }
+    ],
+    "isError": true
+  }
+}

+ 12 - 0
mcp/testdata/snapshots/TestMCP_Mutate-CreateTask

@@ -0,0 +1,12 @@
+mutate_create_task
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "content": [
+      {
+        "text": "{\"createTask\":{\"content\":\"Test content\",\"createdBy\":{\"email\":\"admin@example.com\",\"roles\":[]},\"createdById\":\"1\",\"priority\":\"high\",\"status\":{\"code\":\"open\",\"label\":\"Open\",\"tasks\":[]},\"title\":\"Test Task\",\"updatedBy\":{\"email\":\"\",\"roles\":[]},\"updatedById\":\"0\"}}",
+        "type": "text"
+      }
+    ]
+  }
+}

+ 12 - 0
mcp/testdata/snapshots/TestMCP_Mutate-CreateUser

@@ -0,0 +1,12 @@
+mutate_create_user
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "content": [
+      {
+        "text": "{\"createUser\":{\"email\":\"newuser@example.com\",\"roles\":[{\"description\":\"Administrator with full access\",\"name\":\"admin\",\"permissions\":[]}]}}",
+        "type": "text"
+      }
+    ]
+  }
+}

+ 5 - 0
mcp/testdata/snapshots/TestMCP_Ping

@@ -0,0 +1,5 @@
+ping
+{
+  "jsonrpc": "2.0",
+  "result": {}
+}

+ 13 - 0
mcp/testdata/snapshots/TestMCP_Query-InvalidQuery

@@ -0,0 +1,13 @@
+query_invalid
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "content": [
+      {
+        "text": "Query parse error: input:1:9: Cannot query field \"nonexistent\" on type \"Query\".\n",
+        "type": "text"
+      }
+    ],
+    "isError": true
+  }
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 6 - 0
mcp/testdata/snapshots/TestMCP_Query-Roles


+ 12 - 0
mcp/testdata/snapshots/TestMCP_Query-Services

@@ -0,0 +1,12 @@
+query_services
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "content": [
+      {
+        "text": "{\"services\":[]}",
+        "type": "text"
+      }
+    ]
+  }
+}

+ 12 - 0
mcp/testdata/snapshots/TestMCP_Query-Tasks

@@ -0,0 +1,12 @@
+query_tasks
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "content": [
+      {
+        "text": "{\"tasks\":[]}",
+        "type": "text"
+      }
+    ]
+  }
+}

+ 12 - 0
mcp/testdata/snapshots/TestMCP_Query-Users

@@ -0,0 +1,12 @@
+query_users
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "content": [
+      {
+        "text": "{\"users\":[{\"email\":\"admin@example.com\",\"roles\":[]}]}",
+        "type": "text"
+      }
+    ]
+  }
+}

+ 13 - 0
mcp/testdata/snapshots/TestMCP_Resources-Read

@@ -0,0 +1,13 @@
+resources_read
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "contents": [
+      {
+        "mimeType": "text/plain",
+        "text": "This is a subscription resource. Use resources/subscribe to receive real-time updates.",
+        "uri": "graphql://subscription/taskCreated"
+      }
+    ]
+  }
+}

+ 8 - 0
mcp/testdata/snapshots/TestMCP_Resources-Subscribe

@@ -0,0 +1,8 @@
+resources_subscribe
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "subscribed": true,
+    "uri": "graphql://subscription/taskCreated"
+  }
+}

+ 8 - 0
mcp/testdata/snapshots/TestMCP_Resources-Unsubscribe

@@ -0,0 +1,8 @@
+resources_unsubscribe
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "unsubscribed": true,
+    "uri": "graphql://subscription/taskUpdated"
+  }
+}

+ 32 - 0
mcp/testdata/snapshots/TestMCP_ResourcesList

@@ -0,0 +1,32 @@
+resources_list
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "resources": [
+      {
+        "description": "Subscribe to task creation events. Receives Task objects when new tasks are created and assigned to you.",
+        "mimeType": "application/json",
+        "name": "taskCreated",
+        "uri": "graphql://subscription/taskCreated"
+      },
+      {
+        "description": "Subscribe to task update events. Receives Task objects when tasks assigned to you are updated.",
+        "mimeType": "application/json",
+        "name": "taskUpdated",
+        "uri": "graphql://subscription/taskUpdated"
+      },
+      {
+        "description": "Subscribe to task deletion events. Receives Task objects when tasks assigned to you are deleted.",
+        "mimeType": "application/json",
+        "name": "taskDeleted",
+        "uri": "graphql://subscription/taskDeleted"
+      },
+      {
+        "description": "Subscribe to new message events. Receives Message objects when messages are sent to you.",
+        "mimeType": "application/json",
+        "name": "messageAdded",
+        "uri": "graphql://subscription/messageAdded"
+      }
+    ]
+  }
+}

+ 64 - 0
mcp/testdata/snapshots/TestMCP_ToolsList

@@ -0,0 +1,64 @@
+tools_list
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "tools": [
+      {
+        "description": "Get GraphQL schema information - types, fields, queries, mutations. Use this to discover the API structure before making queries or mutations.",
+        "inputSchema": {
+          "additionalProperties": false,
+          "properties": {
+            "typeName": {
+              "description": "Optional - specific type to introspect (e.g., 'Query', 'Mutation', 'User', 'Task'). If omitted, returns full schema overview.",
+              "type": "string"
+            }
+          },
+          "type": "object"
+        },
+        "name": "introspect"
+      },
+      {
+        "description": "Execute GraphQL queries (read operations). Use for fetching data from the API. The query must be a valid GraphQL query string.",
+        "inputSchema": {
+          "additionalProperties": false,
+          "properties": {
+            "query": {
+              "description": "GraphQL query string (e.g., 'query { users { id email } }')",
+              "type": "string"
+            },
+            "variables": {
+              "description": "Optional query variables as key-value pairs",
+              "type": "object"
+            }
+          },
+          "required": [
+            "query"
+          ],
+          "type": "object"
+        },
+        "name": "query"
+      },
+      {
+        "description": "Execute GraphQL mutations (create/update/delete operations). Use for modifying data in the API. The mutation must be a valid GraphQL mutation string.",
+        "inputSchema": {
+          "additionalProperties": false,
+          "properties": {
+            "mutation": {
+              "description": "GraphQL mutation string (e.g., 'mutation { createUser(input: {email: \"test@example.com\", password: \"pass\", roles: []}) { id } }')",
+              "type": "string"
+            },
+            "variables": {
+              "description": "Optional mutation variables as key-value pairs",
+              "type": "object"
+            }
+          },
+          "required": [
+            "mutation"
+          ],
+          "type": "object"
+        },
+        "name": "mutate"
+      }
+    ]
+  }
+}

+ 210 - 0
mcp/tools/introspect.go

@@ -0,0 +1,210 @@
+package tools
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/vektah/gqlparser/v2/ast"
+)
+
+// CallToolResult represents the result of a tool call
+type CallToolResult struct {
+	Content []ContentBlock `json:"content"`
+	IsError bool           `json:"isError,omitempty"`
+}
+
+// ContentBlock represents a content block in a tool result
+type ContentBlock struct {
+	Type string `json:"type"`
+	Text string `json:"text"`
+}
+
+// Introspect returns GraphQL schema information
+func Introspect(ctx context.Context, schema *ast.Schema, args map[string]interface{}) (CallToolResult, error) {
+	typeName, _ := args["typeName"].(string)
+
+	var result strings.Builder
+
+	if typeName != "" {
+		// Introspect specific type
+		result.WriteString(introspectType(schema, typeName))
+	} else {
+		// Full schema overview
+		result.WriteString("# ARP GraphQL Schema Overview\n\n")
+
+		// Query type
+		result.WriteString("## Query Type\n")
+		result.WriteString(introspectType(schema, "Query"))
+		result.WriteString("\n")
+
+		// Mutation type
+		result.WriteString("## Mutation Type\n")
+		result.WriteString(introspectType(schema, "Mutation"))
+		result.WriteString("\n")
+
+		// Subscription type
+		result.WriteString("## Subscription Type\n")
+		result.WriteString(introspectType(schema, "Subscription"))
+		result.WriteString("\n")
+
+		// Object types
+		result.WriteString("## Object Types\n")
+		for _, t := range schema.Types {
+			if t.Kind == ast.Object && !strings.HasPrefix(t.Name, "__") && t.Name != "Query" && t.Name != "Mutation" && t.Name != "Subscription" {
+				result.WriteString(fmt.Sprintf("### %s\n", t.Name))
+				result.WriteString(introspectType(schema, t.Name))
+				result.WriteString("\n")
+			}
+		}
+
+		// Input types
+		result.WriteString("## Input Types\n")
+		for _, t := range schema.Types {
+			if t.Kind == ast.InputObject && !strings.HasPrefix(t.Name, "__") {
+				result.WriteString(fmt.Sprintf("### %s\n", t.Name))
+				result.WriteString(introspectInputType(t))
+				result.WriteString("\n")
+			}
+		}
+	}
+
+	return CallToolResult{
+		Content: []ContentBlock{
+			{Type: "text", Text: result.String()},
+		},
+	}, nil
+}
+
+func introspectType(schema *ast.Schema, typeName string) string {
+	t := schema.Types[typeName]
+	if t == nil {
+		return fmt.Sprintf("Type '%s' not found\n", typeName)
+	}
+
+	var result strings.Builder
+
+	switch t.Kind {
+	case ast.Object:
+		result.WriteString(fmt.Sprintf("Type: %s (Object)\n", t.Name))
+		if t.Description != "" {
+			result.WriteString(fmt.Sprintf("Description: %s\n", t.Description))
+		}
+		result.WriteString("\nFields:\n")
+		for _, field := range t.Fields {
+			result.WriteString(formatField(field))
+		}
+	case ast.Interface:
+		result.WriteString(fmt.Sprintf("Type: %s (Interface)\n", t.Name))
+		if t.Description != "" {
+			result.WriteString(fmt.Sprintf("Description: %s\n", t.Description))
+		}
+		result.WriteString("\nFields:\n")
+		for _, field := range t.Fields {
+			result.WriteString(formatField(field))
+		}
+	case ast.Union:
+		result.WriteString(fmt.Sprintf("Type: %s (Union)\n", t.Name))
+		result.WriteString("Members:\n")
+		for _, member := range t.Types {
+			result.WriteString(fmt.Sprintf("  - %s\n", member))
+		}
+	case ast.Enum:
+		result.WriteString(fmt.Sprintf("Type: %s (Enum)\n", t.Name))
+		result.WriteString("Values:\n")
+		for _, val := range t.EnumValues {
+			result.WriteString(fmt.Sprintf("  - %s\n", val.Name))
+		}
+	default:
+		result.WriteString(fmt.Sprintf("Type: %s (%s)\n", t.Name, t.Kind))
+	}
+
+	return result.String()
+}
+
+func introspectInputType(t *ast.Definition) string {
+	var result strings.Builder
+
+	result.WriteString(fmt.Sprintf("Type: %s (Input)\n", t.Name))
+	if t.Description != "" {
+		result.WriteString(fmt.Sprintf("Description: %s\n", t.Description))
+	}
+	result.WriteString("\nFields:\n")
+	for _, field := range t.Fields {
+		result.WriteString(formatInputField(field))
+	}
+
+	return result.String()
+}
+
+func formatField(field *ast.FieldDefinition) string {
+	var result strings.Builder
+
+	// Field name and type
+	result.WriteString(fmt.Sprintf("  %s", field.Name))
+	if len(field.Arguments) > 0 {
+		result.WriteString("(")
+		for i, arg := range field.Arguments {
+			if i > 0 {
+				result.WriteString(", ")
+			}
+			result.WriteString(fmt.Sprintf("%s: %s", arg.Name, formatType(arg.Type)))
+		}
+		result.WriteString(")")
+	}
+	result.WriteString(fmt.Sprintf(": %s", formatType(field.Type)))
+
+	// Description
+	if field.Description != "" {
+		result.WriteString(fmt.Sprintf(" - %s", field.Description))
+	}
+
+	// Deprecation
+	if field.Directives != nil {
+		for _, d := range field.Directives {
+			if d.Name == "deprecated" {
+				result.WriteString(" [DEPRECATED]")
+				if reason := d.Arguments.ForName("reason"); reason != nil {
+					result.WriteString(fmt.Sprintf(" - %s", reason.Value.Raw))
+				}
+			}
+		}
+	}
+
+	result.WriteString("\n")
+	return result.String()
+}
+
+func formatInputField(field *ast.FieldDefinition) string {
+	var result strings.Builder
+
+	result.WriteString(fmt.Sprintf("  %s: %s", field.Name, formatType(field.Type)))
+
+	if field.Description != "" {
+		result.WriteString(fmt.Sprintf(" - %s", field.Description))
+	}
+
+	result.WriteString("\n")
+	return result.String()
+}
+
+func formatType(t *ast.Type) string {
+	if t == nil {
+		return "Unknown"
+	}
+
+	base := t.Name()
+	if t.NamedType != "" {
+		base = t.NamedType
+	}
+
+	if t.Elem != nil {
+		return fmt.Sprintf("[%s]", formatType(t.Elem))
+	}
+
+	if t.NonNull {
+		return base + "!"
+	}
+
+	return base
+}

+ 658 - 0
mcp/tools/mutate.go

@@ -0,0 +1,658 @@
+package tools
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"github.com/vektah/gqlparser/v2"
+	"github.com/vektah/gqlparser/v2/ast"
+	"gogs.dmsc.dev/arp/graph"
+	"gogs.dmsc.dev/arp/graph/model"
+)
+
+// Mutate executes a GraphQL mutation
+func Mutate(ctx context.Context, resolver *graph.Resolver, schema *ast.Schema, args map[string]interface{}) (CallToolResult, error) {
+	mutationStr, ok := args["mutation"].(string)
+	if !ok {
+		return CallToolResult{
+			Content: []ContentBlock{
+				{Type: "text", Text: "Missing required 'mutation' parameter"},
+			},
+			IsError: true,
+		}, nil
+	}
+
+	// Parse variables
+	variables := make(map[string]interface{})
+	if v, ok := args["variables"].(map[string]interface{}); ok {
+		variables = v
+	}
+
+	// Parse the mutation
+	mutationDoc, err := gqlparser.LoadQuery(schema, mutationStr)
+	if err != nil {
+		return CallToolResult{
+			Content: []ContentBlock{
+				{Type: "text", Text: fmt.Sprintf("Mutation parse error: %v", err)},
+			},
+			IsError: true,
+		}, nil
+	}
+
+	// Execute each operation
+	var results []interface{}
+	for _, op := range mutationDoc.Operations {
+		if op.Operation != ast.Mutation {
+			continue
+		}
+
+		result, errMsg := executeMutation(ctx, resolver, schema, mutationDoc, op, variables)
+		if errMsg != "" {
+			return CallToolResult{
+				Content: []ContentBlock{
+					{Type: "text", Text: errMsg},
+				},
+				IsError: true,
+			}, nil
+		}
+		results = append(results, result)
+	}
+
+	// Format response
+	var responseText string
+	if len(results) == 1 {
+		bytes, _ := json.MarshalIndent(results[0], "", "  ")
+		responseText = string(bytes)
+	} else {
+		bytes, _ := json.MarshalIndent(results, "", "  ")
+		responseText = string(bytes)
+	}
+
+	return CallToolResult{
+		Content: []ContentBlock{
+			{Type: "text", Text: responseText},
+		},
+	}, nil
+}
+
+func executeMutation(ctx context.Context, resolver *graph.Resolver, schema *ast.Schema, doc *ast.QueryDocument, op *ast.OperationDefinition, variables map[string]interface{}) (map[string]interface{}, string) {
+	result := make(map[string]interface{})
+
+	for _, sel := range op.SelectionSet {
+		field, ok := sel.(*ast.Field)
+		if !ok {
+			continue
+		}
+
+		value, errMsg := resolveMutationField(ctx, resolver, schema, field, variables)
+		if errMsg != "" {
+			return nil, errMsg
+		}
+		result[field.Alias] = value
+	}
+
+	return result, ""
+}
+
+func resolveMutationField(ctx context.Context, resolver *graph.Resolver, schema *ast.Schema, field *ast.Field, variables map[string]interface{}) (interface{}, string) {
+	// Get field arguments
+	args := make(map[string]interface{})
+	for _, arg := range field.Arguments {
+		value, err := arg.Value.Value(variables)
+		if err != nil {
+			return nil, fmt.Sprintf("failed to evaluate argument %s: %v", arg.Name, err)
+		}
+		args[arg.Name] = value
+	}
+
+	// Resolve based on field name
+	switch field.Name {
+	// User mutations
+	case "createUser":
+		input := parseNewUser(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for createUser"
+		}
+		user, err := resolver.Mutation().CreateUser(ctx, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return user, ""
+
+	case "updateUser":
+		id, _ := args["id"].(string)
+		input := parseUpdateUserInput(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for updateUser"
+		}
+		user, err := resolver.Mutation().UpdateUser(ctx, id, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return user, ""
+
+	case "deleteUser":
+		id, _ := args["id"].(string)
+		user, err := resolver.Mutation().DeleteUser(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return user, ""
+
+	// Note mutations
+	case "createNote":
+		input := parseNewNote(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for createNote"
+		}
+		note, err := resolver.Mutation().CreateNote(ctx, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return note, ""
+
+	case "updateNote":
+		id, _ := args["id"].(string)
+		input := parseUpdateNoteInput(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for updateNote"
+		}
+		note, err := resolver.Mutation().UpdateNote(ctx, id, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return note, ""
+
+	case "deleteNote":
+		id, _ := args["id"].(string)
+		note, err := resolver.Mutation().DeleteNote(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return note, ""
+
+	// Role mutations
+	case "createRole":
+		input := parseNewRole(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for createRole"
+		}
+		role, err := resolver.Mutation().CreateRole(ctx, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return role, ""
+
+	case "updateRole":
+		id, _ := args["id"].(string)
+		input := parseUpdateRoleInput(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for updateRole"
+		}
+		role, err := resolver.Mutation().UpdateRole(ctx, id, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return role, ""
+
+	case "deleteRole":
+		id, _ := args["id"].(string)
+		role, err := resolver.Mutation().DeleteRole(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return role, ""
+
+	// Permission mutations
+	case "createPermission":
+		input := parseNewPermission(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for createPermission"
+		}
+		perm, err := resolver.Mutation().CreatePermission(ctx, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return perm, ""
+
+	case "updatePermission":
+		id, _ := args["id"].(string)
+		input := parseUpdatePermissionInput(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for updatePermission"
+		}
+		perm, err := resolver.Mutation().UpdatePermission(ctx, id, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return perm, ""
+
+	case "deletePermission":
+		id, _ := args["id"].(string)
+		perm, err := resolver.Mutation().DeletePermission(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return perm, ""
+
+	// Service mutations
+	case "createService":
+		input := parseNewService(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for createService"
+		}
+		service, err := resolver.Mutation().CreateService(ctx, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return service, ""
+
+	case "updateService":
+		id, _ := args["id"].(string)
+		input := parseUpdateServiceInput(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for updateService"
+		}
+		service, err := resolver.Mutation().UpdateService(ctx, id, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return service, ""
+
+	case "deleteService":
+		id, _ := args["id"].(string)
+		service, err := resolver.Mutation().DeleteService(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return service, ""
+
+	// Task mutations
+	case "createTask":
+		input := parseNewTask(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for createTask"
+		}
+		task, err := resolver.Mutation().CreateTask(ctx, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return task, ""
+
+	case "updateTask":
+		id, _ := args["id"].(string)
+		input := parseUpdateTaskInput(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for updateTask"
+		}
+		task, err := resolver.Mutation().UpdateTask(ctx, id, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return task, ""
+
+	case "deleteTask":
+		id, _ := args["id"].(string)
+		task, err := resolver.Mutation().DeleteTask(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return task, ""
+
+	// TaskStatus mutations
+	case "createTaskStatus":
+		input := parseNewTaskStatus(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for createTaskStatus"
+		}
+		status, err := resolver.Mutation().CreateTaskStatus(ctx, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return status, ""
+
+	case "updateTaskStatus":
+		id, _ := args["id"].(string)
+		input := parseUpdateTaskStatusInput(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for updateTaskStatus"
+		}
+		status, err := resolver.Mutation().UpdateTaskStatus(ctx, id, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return status, ""
+
+	case "deleteTaskStatus":
+		id, _ := args["id"].(string)
+		status, err := resolver.Mutation().DeleteTaskStatus(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return status, ""
+
+	// Message mutations
+	case "createMessage":
+		input := parseNewMessage(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for createMessage"
+		}
+		msg, err := resolver.Mutation().CreateMessage(ctx, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return msg, ""
+
+	case "updateMessage":
+		id, _ := args["id"].(string)
+		input := parseUpdateMessageInput(args["input"])
+		if input == nil {
+			return nil, "missing or invalid input for updateMessage"
+		}
+		msg, err := resolver.Mutation().UpdateMessage(ctx, id, *input)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return msg, ""
+
+	case "deleteMessage":
+		id, _ := args["id"].(string)
+		msg, err := resolver.Mutation().DeleteMessage(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return msg, ""
+
+	default:
+		return nil, fmt.Sprintf("unknown mutation: %s", field.Name)
+	}
+}
+
+// Helper functions
+
+func getString(m map[string]interface{}, key string) string {
+	if v, ok := m[key]; ok {
+		if s, ok := v.(string); ok {
+			return s
+		}
+	}
+	return ""
+}
+
+func parseStringSlice(v interface{}) []string {
+	if v == nil {
+		return nil
+	}
+	if arr, ok := v.([]interface{}); ok {
+		result := make([]string, 0, len(arr))
+		for _, item := range arr {
+			if s, ok := item.(string); ok {
+				result = append(result, s)
+			}
+		}
+		return result
+	}
+	if arr, ok := v.([]string); ok {
+		return arr
+	}
+	return nil
+}
+
+func parseStringPtr(v interface{}) *string {
+	if v == nil {
+		return nil
+	}
+	if s, ok := v.(string); ok {
+		return &s
+	}
+	return nil
+}
+
+// Input parsing functions
+
+func parseNewUser(v interface{}) *model.NewUser {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.NewUser{
+		Email:    getString(m, "email"),
+		Password: getString(m, "password"),
+		Roles:    parseStringSlice(m["roles"]),
+	}
+}
+
+func parseUpdateUserInput(v interface{}) *model.UpdateUserInput {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.UpdateUserInput{
+		Email:    parseStringPtr(m["email"]),
+		Password: parseStringPtr(m["password"]),
+		Roles:    parseStringSlice(m["roles"]),
+	}
+}
+
+func parseNewNote(v interface{}) *model.NewNote {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.NewNote{
+		Title:     getString(m, "title"),
+		Content:   getString(m, "content"),
+		UserID:    getString(m, "userId"),
+		ServiceID: getString(m, "serviceId"),
+	}
+}
+
+func parseUpdateNoteInput(v interface{}) *model.UpdateNoteInput {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.UpdateNoteInput{
+		Title:     parseStringPtr(m["title"]),
+		Content:   parseStringPtr(m["content"]),
+		UserID:    parseStringPtr(m["userId"]),
+		ServiceID: parseStringPtr(m["serviceId"]),
+	}
+}
+
+func parseNewRole(v interface{}) *model.NewRole {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.NewRole{
+		Name:        getString(m, "name"),
+		Description: getString(m, "description"),
+		Permissions: parseStringSlice(m["permissions"]),
+	}
+}
+
+func parseUpdateRoleInput(v interface{}) *model.UpdateRoleInput {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.UpdateRoleInput{
+		Name:        parseStringPtr(m["name"]),
+		Description: parseStringPtr(m["description"]),
+		Permissions: parseStringSlice(m["permissions"]),
+	}
+}
+
+func parseNewPermission(v interface{}) *model.NewPermission {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.NewPermission{
+		Code:        getString(m, "code"),
+		Description: getString(m, "description"),
+	}
+}
+
+func parseUpdatePermissionInput(v interface{}) *model.UpdatePermissionInput {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.UpdatePermissionInput{
+		Code:        parseStringPtr(m["code"]),
+		Description: parseStringPtr(m["description"]),
+	}
+}
+
+func parseNewService(v interface{}) *model.NewService {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.NewService{
+		Name:         getString(m, "name"),
+		Description:  parseStringPtr(m["description"]),
+		CreatedByID:  getString(m, "createdById"),
+		Participants: parseStringSlice(m["participants"]),
+	}
+}
+
+func parseUpdateServiceInput(v interface{}) *model.UpdateServiceInput {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.UpdateServiceInput{
+		Name:         parseStringPtr(m["name"]),
+		Description:  parseStringPtr(m["description"]),
+		CreatedByID:  parseStringPtr(m["createdById"]),
+		Participants: parseStringSlice(m["participants"]),
+	}
+}
+
+func parseNewTask(v interface{}) *model.NewTask {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.NewTask{
+		Title:       getString(m, "title"),
+		Content:     getString(m, "content"),
+		CreatedByID: getString(m, "createdById"),
+		AssigneeID:  parseStringPtr(m["assigneeId"]),
+		StatusID:    parseStringPtr(m["statusId"]),
+		DueDate:     parseStringPtr(m["dueDate"]),
+		Priority:    getString(m, "priority"),
+	}
+}
+
+func parseUpdateTaskInput(v interface{}) *model.UpdateTaskInput {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.UpdateTaskInput{
+		Title:       parseStringPtr(m["title"]),
+		Content:     parseStringPtr(m["content"]),
+		CreatedByID: parseStringPtr(m["createdById"]),
+		AssigneeID:  parseStringPtr(m["assigneeId"]),
+		StatusID:    parseStringPtr(m["statusId"]),
+		DueDate:     parseStringPtr(m["dueDate"]),
+		Priority:    parseStringPtr(m["priority"]),
+	}
+}
+
+func parseNewTaskStatus(v interface{}) *model.NewTaskStatus {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.NewTaskStatus{
+		Code:  getString(m, "code"),
+		Label: getString(m, "label"),
+	}
+}
+
+func parseUpdateTaskStatusInput(v interface{}) *model.UpdateTaskStatusInput {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.UpdateTaskStatusInput{
+		Code:  parseStringPtr(m["code"]),
+		Label: parseStringPtr(m["label"]),
+	}
+}
+
+func parseNewMessage(v interface{}) *model.NewMessage {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.NewMessage{
+		Content:   getString(m, "content"),
+		Receivers: parseStringSlice(m["receivers"]),
+	}
+}
+
+func parseUpdateMessageInput(v interface{}) *model.UpdateMessageInput {
+	if v == nil {
+		return nil
+	}
+	m, ok := v.(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return &model.UpdateMessageInput{
+		Content:   parseStringPtr(m["content"]),
+		Receivers: parseStringSlice(m["receivers"]),
+	}
+}

+ 233 - 0
mcp/tools/query.go

@@ -0,0 +1,233 @@
+package tools
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"github.com/vektah/gqlparser/v2"
+	"github.com/vektah/gqlparser/v2/ast"
+	"gogs.dmsc.dev/arp/graph"
+)
+
+// Query executes a GraphQL query
+func Query(ctx context.Context, resolver *graph.Resolver, schema *ast.Schema, args map[string]interface{}) (CallToolResult, error) {
+	queryStr, ok := args["query"].(string)
+	if !ok {
+		return CallToolResult{
+			Content: []ContentBlock{
+				{Type: "text", Text: "Missing required 'query' parameter"},
+			},
+			IsError: true,
+		}, nil
+	}
+
+	// Parse variables
+	variables := make(map[string]interface{})
+	if v, ok := args["variables"].(map[string]interface{}); ok {
+		variables = v
+	}
+
+	// Parse the query
+	queryDoc, err := gqlparser.LoadQuery(schema, queryStr)
+	if err != nil {
+		return CallToolResult{
+			Content: []ContentBlock{
+				{Type: "text", Text: fmt.Sprintf("Query parse error: %v", err)},
+			},
+			IsError: true,
+		}, nil
+	}
+
+	// Execute each operation
+	var results []interface{}
+	for _, op := range queryDoc.Operations {
+		if op.Operation != ast.Query {
+			continue
+		}
+
+		result, errMsg := executeQuery(ctx, resolver, schema, queryDoc, op, variables)
+		if errMsg != "" {
+			return CallToolResult{
+				Content: []ContentBlock{
+					{Type: "text", Text: errMsg},
+				},
+				IsError: true,
+			}, nil
+		}
+		results = append(results, result)
+	}
+
+	// Format response
+	var responseText string
+	if len(results) == 1 {
+		bytes, _ := json.MarshalIndent(results[0], "", "  ")
+		responseText = string(bytes)
+	} else {
+		bytes, _ := json.MarshalIndent(results, "", "  ")
+		responseText = string(bytes)
+	}
+
+	return CallToolResult{
+		Content: []ContentBlock{
+			{Type: "text", Text: responseText},
+		},
+	}, nil
+}
+
+func executeQuery(ctx context.Context, resolver *graph.Resolver, schema *ast.Schema, doc *ast.QueryDocument, op *ast.OperationDefinition, variables map[string]interface{}) (map[string]interface{}, string) {
+	result := make(map[string]interface{})
+
+	for _, sel := range op.SelectionSet {
+		field, ok := sel.(*ast.Field)
+		if !ok {
+			continue
+		}
+
+		value, errMsg := resolveQueryField(ctx, resolver, schema, field, variables)
+		if errMsg != "" {
+			return nil, errMsg
+		}
+		result[field.Alias] = value
+	}
+
+	return result, ""
+}
+
+func resolveQueryField(ctx context.Context, resolver *graph.Resolver, schema *ast.Schema, field *ast.Field, variables map[string]interface{}) (interface{}, string) {
+	// Get field arguments
+	args := make(map[string]interface{})
+	for _, arg := range field.Arguments {
+		value, err := arg.Value.Value(variables)
+		if err != nil {
+			return nil, fmt.Sprintf("failed to evaluate argument %s: %v", arg.Name, err)
+		}
+		args[arg.Name] = value
+	}
+
+	// Resolve based on field name
+	switch field.Name {
+	case "users":
+		users, err := resolver.Query().Users(ctx)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return users, ""
+
+	case "user":
+		id, _ := args["id"].(string)
+		user, err := resolver.Query().User(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return user, ""
+
+	case "notes":
+		notes, err := resolver.Query().Notes(ctx)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return notes, ""
+
+	case "note":
+		id, _ := args["id"].(string)
+		note, err := resolver.Query().Note(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return note, ""
+
+	case "roles":
+		roles, err := resolver.Query().Roles(ctx)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return roles, ""
+
+	case "role":
+		id, _ := args["id"].(string)
+		role, err := resolver.Query().Role(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return role, ""
+
+	case "permissions":
+		perms, err := resolver.Query().Permissions(ctx)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return perms, ""
+
+	case "permission":
+		id, _ := args["id"].(string)
+		perm, err := resolver.Query().Permission(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return perm, ""
+
+	case "services":
+		services, err := resolver.Query().Services(ctx)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return services, ""
+
+	case "service":
+		id, _ := args["id"].(string)
+		service, err := resolver.Query().Service(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return service, ""
+
+	case "tasks":
+		tasks, err := resolver.Query().Tasks(ctx)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return tasks, ""
+
+	case "task":
+		id, _ := args["id"].(string)
+		task, err := resolver.Query().Task(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return task, ""
+
+	case "taskStatuses":
+		statuses, err := resolver.Query().TaskStatuses(ctx)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return statuses, ""
+
+	case "taskStatus":
+		id, _ := args["id"].(string)
+		status, err := resolver.Query().TaskStatus(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return status, ""
+
+	case "messages":
+		messages, err := resolver.Query().Messages(ctx)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return messages, ""
+
+	case "message":
+		id, _ := args["id"].(string)
+		message, err := resolver.Query().Message(ctx, id)
+		if err != nil {
+			return nil, err.Error()
+		}
+		return message, ""
+
+	default:
+		return nil, fmt.Sprintf("unknown field: %s", field.Name)
+	}
+}

+ 22 - 0
server.go

@@ -2,6 +2,7 @@ package main
 
 import (
 	"context"
+	"embed"
 	"log"
 	"net/http"
 	"os"
@@ -11,14 +12,19 @@ import (
 	"github.com/99designs/gqlgen/graphql/handler/lru"
 	"github.com/99designs/gqlgen/graphql/handler/transport"
 	"github.com/99designs/gqlgen/graphql/playground"
+	"github.com/vektah/gqlparser/v2"
 	"github.com/vektah/gqlparser/v2/ast"
 	"gogs.dmsc.dev/arp/auth"
 	"gogs.dmsc.dev/arp/graph"
+	"gogs.dmsc.dev/arp/mcp"
 	"gogs.dmsc.dev/arp/models"
 	"gorm.io/driver/sqlite"
 	"gorm.io/gorm"
 )
 
+//go:embed graph/schema.graphqls
+var schemaFS embed.FS
+
 const defaultPort = "8080"
 
 func main() {
@@ -82,9 +88,25 @@ func main() {
 		Cache: lru.New[string](100),
 	})
 
+	// Load GraphQL schema for MCP server (from embedded FS)
+	schemaStr, err := schemaFS.ReadFile("graph/schema.graphqls")
+	if err != nil {
+		log.Fatal("failed to read embedded schema file:", err)
+	}
+	schema := gqlparser.MustLoadSchema(&ast.Source{
+		Name:  "schema.graphqls",
+		Input: string(schemaStr),
+	})
+
+	// Create MCP server
+	mcpServer := mcp.NewServer(resolver, schema)
+
 	http.Handle("/", playground.Handler("GraphQL playground", "/query"))
 	http.Handle("/query", auth.AuthMiddleware(srv))
+	http.Handle("/mcp", auth.AuthMiddleware(mcpServer))
+	http.Handle("/message", auth.AuthMiddleware(http.HandlerFunc(mcpServer.HandleMessage)))
 
 	log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
+	log.Printf("MCP server available at http://localhost:%s/mcp", port)
 	log.Fatal(http.ListenAndServe(":"+port, nil))
 }

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است