fix readme a bit
Some checks failed
build-container / build (push) Has been cancelled

This commit is contained in:
Ra
2025-09-12 04:45:09 -07:00
parent d22675fd16
commit ee30aca4d7
16 changed files with 826 additions and 572 deletions

373
README.md
View File

@@ -65,204 +65,209 @@ The coordinator automatically manages external MCP servers based on configuratio
} }
``` ```
## Prerequisites ## Setup
Choose one of these installation methods: Choose one of these installation methods:
[Docker](#1-start-nats-server) <details>
<summary>Docker</summary>
[Manual Installation](#manual-setup) ### 1. Start NATS Server
- **Elixir**: 1.16+ with OTP 26+ First, start a NATS server that the Agent Coordinator can connect to:
- **Node.js**: 18+ (for some MCP servers)
- **uv**: If using python MCP servers
### Docker Setup ```bash
# Start NATS server with persistent storage
docker run -d \
--name nats-server \
--network agent-coordinator-net \
-p 4222:4222 \
-p 8222:8222 \
-v nats_data:/data \
nats:2.10-alpine \
--jetstream \
--store_dir=/data \
--max_mem_store=1Gb \
--max_file_store=10Gb
#### 1. Start NATS Server # Create the network first if it doesn't exist
docker network create agent-coordinator-net
```
First, start a NATS server that the Agent Coordinator can connect to: ### 2. Configure Your AI Tools
```bash **For STDIO Mode (Recommended - Direct MCP Integration):**
# Start NATS server with persistent storage
docker run -d \
--name nats-server \
--network agent-coordinator-net \
-p 4222:4222 \
-p 8222:8222 \
-v nats_data:/data \
nats:2.10-alpine \
--jetstream \
--store_dir=/data \
--max_mem_store=1Gb \
--max_file_store=10Gb
# Create the network first if it doesn't exist First, create a Docker network and start the NATS server:
docker network create agent-coordinator-net
```
#### 2. Configure Your AI Tools ```bash
# Create network for secure communication
docker network create agent-coordinator-net
**For STDIO Mode (Recommended - Direct MCP Integration):** # Start NATS server
docker run -d \
--name nats-server \
--network agent-coordinator-net \
-p 4222:4222 \
-v nats_data:/data \
nats:2.10-alpine \
--jetstream \
--store_dir=/data \
--max_mem_store=1Gb \
--max_file_store=10Gb
```
First, create a Docker network and start the NATS server: Then add this configuration to your VS Code `mcp.json` configuration file via `ctrl + shift + p` → `MCP: Open User Configuration` or `MCP: Open Remote User Configuration` if running on a remote server:
```bash ```json
# Create network for secure communication {
docker network create agent-coordinator-net "servers": {
"agent-coordinator": {
# Start NATS server "command": "docker",
docker run -d \ "args": [
--name nats-server \ "run",
--network agent-coordinator-net \ "--network=agent-coordinator-net",
-p 4222:4222 \ "-v=./mcp_servers.json:/app/mcp_servers.json:ro",
-v nats_data:/data \ "-v=/path/to/your/workspace:/workspace:rw",
nats:2.10-alpine \ "-e=NATS_HOST=nats-server",
--jetstream \ "-e=NATS_PORT=4222",
--store_dir=/data \ "-i",
--max_mem_store=1Gb \ "--rm",
--max_file_store=10Gb "ghcr.io/rooba/agentcoordinator:latest"
``` ],
"type": "stdio"
Then add this configuration to your VS Code `mcp.json` configuration file via `ctrl + shift + p``MCP: Open User Configuration` or `MCP: Open Remote User Configuration` if running on a remote server:
```json
{
"servers": {
"agent-coordinator": {
"command": "docker",
"args": [
"run",
"--network=agent-coordinator-net",
"-v=./mcp_servers.json:/app/mcp_servers.json:ro",
"-v=/path/to/your/workspace:/workspace:rw",
"-e=NATS_HOST=nats-server",
"-e=NATS_PORT=4222",
"-i",
"--rm",
"ghcr.io/rooba/agentcoordinator:latest"
],
"type": "stdio"
}
}
}
```
**Important Notes for File System Access:**
If you're using MCP filesystem servers, mount the directories they need access to:
```json
{
"args": [
"run",
"--network=agent-coordinator-net",
"-v=./mcp_servers.json:/app/mcp_servers.json:ro",
"-v=/home/user/projects:/home/user/projects:rw",
"-v=/path/to/workspace:/workspace:rw",
"-e=NATS_HOST=nats-server",
"-e=NATS_PORT=4222",
"-i",
"--rm",
"ghcr.io/rooba/agentcoordinator:latest"
]
}
```
**For HTTP/WebSocket Mode (Alternative - Web API Access):**
If you prefer to run as a web service instead of stdio:
```bash
# Create network first
docker network create agent-coordinator-net
# Start NATS server
docker run -d \
--name nats-server \
--network agent-coordinator-net \
-p 4222:4222 \
-v nats_data:/data \
nats:2.10-alpine \
--jetstream \
--store_dir=/data \
--max_mem_store=1Gb \
--max_file_store=10Gb
# Run Agent Coordinator in HTTP mode
docker run -d \
--name agent-coordinator \
--network agent-coordinator-net \
-p 8080:4000 \
-v ./mcp_servers.json:/app/mcp_servers.json:ro \
-v /path/to/workspace:/workspace:rw \
-e NATS_HOST=nats-server \
-e NATS_PORT=4222 \
-e MCP_INTERFACE_MODE=http \
-e MCP_HTTP_PORT=4000 \
ghcr.io/rooba/agentcoordinator:latest
```
Then access via HTTP API at `http://localhost:8080/mcp` or configure your MCP client to use the HTTP endpoint.
Create or edit `mcp_servers.json` in your project directory to configure external MCP servers:
```json
{
"servers": {
"mcp_filesystem": {
"type": "stdio",
"command": "bunx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
"auto_restart": true
}
}
}
```
### Manual Setup
#### Clone the Repository
> It is suggested to install Elixir (and Erlang) via [asdf](https://asdf-vm.com/) for easy version management.
> NATS can be found at [nats.io](https://github.com/nats-io/nats-server/releases/latest), or via Docker
```bash
git clone https://github.com/rooba/agentcoordinator.git
cd agentcoordinator
mix deps.get
mix compile
```
#### Start the MCP Server directly
```bash
# Start the MCP server directly
export MCP_INTERFACE_MODE=stdio # or http / websocket
# export MCP_HTTP_PORT=4000 # if using http mode
./scripts/mcp_launcher.sh
# Or in development mode
mix run --no-halt
```
### Run via VS Code or similar tools
Add this to your `mcp.json` or `mcp_servers.json` depending on your tool:
```json
{
"servers": {
"agent-coordinator": {
"command": "/path/to/agent_coordinator/scripts/mcp_launcher.sh",
"args": [],
"env": {
"MIX_ENV": "prod",
"NATS_HOST": "localhost",
"NATS_PORT": "4222"
} }
} }
} }
} ```
```
**Important Notes for File System Access:**
If you're using MCP filesystem servers, mount the directories they need access to:
```json
{
"args": [
"run",
"--network=agent-coordinator-net",
"-v=./mcp_servers.json:/app/mcp_servers.json:ro",
"-v=/home/user/projects:/home/user/projects:rw",
"-v=/path/to/workspace:/workspace:rw",
"-e=NATS_HOST=nats-server",
"-e=NATS_PORT=4222",
"-i",
"--rm",
"ghcr.io/rooba/agentcoordinator:latest"
]
}
```
**For HTTP/WebSocket Mode (Alternative - Web API Access):**
If you prefer to run as a web service instead of stdio:
```bash
# Create network first
docker network create agent-coordinator-net
# Start NATS server
docker run -d \
--name nats-server \
--network agent-coordinator-net \
-p 4222:4222 \
-v nats_data:/data \
nats:2.10-alpine \
--jetstream \
--store_dir=/data \
--max_mem_store=1Gb \
--max_file_store=10Gb
# Run Agent Coordinator in HTTP mode
docker run -d \
--name agent-coordinator \
--network agent-coordinator-net \
-p 8080:4000 \
-v ./mcp_servers.json:/app/mcp_servers.json:ro \
-v /path/to/workspace:/workspace:rw \
-e NATS_HOST=nats-server \
-e NATS_PORT=4222 \
-e MCP_INTERFACE_MODE=http \
-e MCP_HTTP_PORT=4000 \
ghcr.io/rooba/agentcoordinator:latest
```
Then access via HTTP API at `http://localhost:8080/mcp` or configure your MCP client to use the HTTP endpoint.
Create or edit `mcp_servers.json` in your project directory to configure external MCP servers:
```json
{
"servers": {
"mcp_filesystem": {
"type": "stdio",
"command": "bunx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
"auto_restart": true
}
}
}
```
</details>
<details>
<summary>Manual Setup</summary>
### Prerequisites
- **Elixir**: 1.16+ with OTP 26+
- **Node.js**: 18+ (for some MCP servers)
- **uv**: If using python MCP servers
### Clone the Repository
It is suggested to install Elixir (and Erlang) via [asdf](https://asdf-vm.com/) for easy version management.
NATS can be found at [nats.io](https://github.com/nats-io/nats-server/releases/latest), or via Docker
```bash
git clone https://github.com/rooba/agentcoordinator.git
cd agentcoordinator
mix deps.get
mix compile
```
### Start the MCP Server directly
```bash
# Start the MCP server directly
export MCP_INTERFACE_MODE=stdio # or http / websocket
# export MCP_HTTP_PORT=4000 # if using http mode
./scripts/mcp_launcher.sh
# Or in development mode
mix run --no-halt
```
### Run via VS Code or similar tools
Add this to your `mcp.json` or `mcp_servers.json` depending on your tool:
```json
{
"servers": {
"agent-coordinator": {
"command": "/path/to/agent_coordinator/scripts/mcp_launcher.sh",
"args": [],
"env": {
"MIX_ENV": "prod",
"NATS_HOST": "localhost",
"NATS_PORT": "4222"
}
}
}
}
```
</details>

View File

@@ -40,8 +40,10 @@ defmodule AgentCoordinator.ActivityTracker do
"move_file" -> "move_file" ->
source = Map.get(args, "source") source = Map.get(args, "source")
dest = Map.get(args, "destination") dest = Map.get(args, "destination")
files = [source, dest] |> Enum.filter(&(&1)) files = [source, dest] |> Enum.filter(& &1)
{"Moving #{Path.basename(source || "file")} to #{Path.basename(dest || "destination")}", files}
{"Moving #{Path.basename(source || "file")} to #{Path.basename(dest || "destination")}",
files}
# VS Code operations # VS Code operations
"vscode_read_file" -> "vscode_read_file" ->
@@ -54,6 +56,7 @@ defmodule AgentCoordinator.ActivityTracker do
"vscode_set_editor_content" -> "vscode_set_editor_content" ->
file_path = Map.get(args, "file_path") file_path = Map.get(args, "file_path")
if file_path do if file_path do
{"Editing #{Path.basename(file_path)} in VS Code", [file_path]} {"Editing #{Path.basename(file_path)} in VS Code", [file_path]}
else else
@@ -114,6 +117,7 @@ defmodule AgentCoordinator.ActivityTracker do
# Test operations # Test operations
"runTests" -> "runTests" ->
files = Map.get(args, "files", []) files = Map.get(args, "files", [])
if files != [] do if files != [] do
file_names = Enum.map(files, &Path.basename/1) file_names = Enum.map(files, &Path.basename/1)
{"Running tests in #{Enum.join(file_names, ", ")}", files} {"Running tests in #{Enum.join(file_names, ", ")}", files}
@@ -153,6 +157,7 @@ defmodule AgentCoordinator.ActivityTracker do
# HTTP/Web operations # HTTP/Web operations
"fetch_webpage" -> "fetch_webpage" ->
urls = Map.get(args, "urls", []) urls = Map.get(args, "urls", [])
if urls != [] do if urls != [] do
{"Fetching #{length(urls)} webpages", []} {"Fetching #{length(urls)} webpages", []}
else else
@@ -162,6 +167,7 @@ defmodule AgentCoordinator.ActivityTracker do
# Development operations # Development operations
"get_errors" -> "get_errors" ->
files = Map.get(args, "filePaths", []) files = Map.get(args, "filePaths", [])
if files != [] do if files != [] do
file_names = Enum.map(files, &Path.basename/1) file_names = Enum.map(files, &Path.basename/1)
{"Checking errors in #{Enum.join(file_names, ", ")}", files} {"Checking errors in #{Enum.join(file_names, ", ")}", files}
@@ -180,6 +186,7 @@ defmodule AgentCoordinator.ActivityTracker do
"elixir-docs" -> "elixir-docs" ->
modules = Map.get(args, "modules", []) modules = Map.get(args, "modules", [])
if modules != [] do if modules != [] do
{"Getting docs for #{Enum.join(modules, ", ")}", []} {"Getting docs for #{Enum.join(modules, ", ")}", []}
else else
@@ -196,6 +203,7 @@ defmodule AgentCoordinator.ActivityTracker do
"pylanceFileSyntaxErrors" -> "pylanceFileSyntaxErrors" ->
file_uri = Map.get(args, "fileUri") file_uri = Map.get(args, "fileUri")
if file_uri do if file_uri do
file_path = uri_to_path(file_uri) file_path = uri_to_path(file_uri)
{"Checking syntax errors in #{Path.basename(file_path)}", [file_path]} {"Checking syntax errors in #{Path.basename(file_path)}", [file_path]}
@@ -268,10 +276,11 @@ defmodule AgentCoordinator.ActivityTracker do
defp extract_file_path(args) do defp extract_file_path(args) do
# Try various common parameter names for file paths # Try various common parameter names for file paths
args["path"] || args["filePath"] || args["file_path"] || args["path"] || args["filePath"] || args["file_path"] ||
args["source"] || args["destination"] || args["fileUri"] |> uri_to_path() args["source"] || args["destination"] || args["fileUri"] |> uri_to_path()
end end
defp uri_to_path(nil), do: nil defp uri_to_path(nil), do: nil
defp uri_to_path(uri) when is_binary(uri) do defp uri_to_path(uri) when is_binary(uri) do
if String.starts_with?(uri, "file://") do if String.starts_with?(uri, "file://") do
String.replace_prefix(uri, "file://", "") String.replace_prefix(uri, "file://", "")

View File

@@ -55,21 +55,25 @@ defmodule AgentCoordinator.Agent do
workspace_path = Keyword.get(opts, :workspace_path) workspace_path = Keyword.get(opts, :workspace_path)
# Use smart codebase identification # Use smart codebase identification
codebase_id = case Keyword.get(opts, :codebase_id) do codebase_id =
nil when workspace_path -> case Keyword.get(opts, :codebase_id) do
# Auto-detect from workspace nil when workspace_path ->
case AgentCoordinator.CodebaseIdentifier.identify_codebase(workspace_path) do # Auto-detect from workspace
%{canonical_id: canonical_id} -> canonical_id case AgentCoordinator.CodebaseIdentifier.identify_codebase(workspace_path) do
_ -> Path.basename(workspace_path || "default") %{canonical_id: canonical_id} -> canonical_id
end _ -> Path.basename(workspace_path || "default")
end
nil -> nil ->
"default" "default"
explicit_id -> explicit_id ->
# Normalize the provided ID # Normalize the provided ID
AgentCoordinator.CodebaseIdentifier.normalize_codebase_reference(explicit_id, workspace_path) AgentCoordinator.CodebaseIdentifier.normalize_codebase_reference(
end explicit_id,
workspace_path
)
end
%__MODULE__{ %__MODULE__{
id: UUID.uuid4(), id: UUID.uuid4(),
@@ -99,23 +103,21 @@ defmodule AgentCoordinator.Agent do
timestamp: DateTime.utc_now() timestamp: DateTime.utc_now()
} }
new_history = [activity_entry | agent.activity_history] new_history =
|> Enum.take(10) [activity_entry | agent.activity_history]
|> Enum.take(10)
%{agent | %{
current_activity: activity, agent
current_files: files, | current_activity: activity,
activity_history: new_history, current_files: files,
last_heartbeat: DateTime.utc_now() activity_history: new_history,
last_heartbeat: DateTime.utc_now()
} }
end end
def clear_activity(agent) do def clear_activity(agent) do
%{agent | %{agent | current_activity: nil, current_files: [], last_heartbeat: DateTime.utc_now()}
current_activity: nil,
current_files: [],
last_heartbeat: DateTime.utc_now()
}
end end
def assign_task(agent, task_id) do def assign_task(agent, task_id) do

View File

@@ -12,15 +12,15 @@ defmodule AgentCoordinator.CodebaseIdentifier do
require Logger require Logger
@type codebase_info :: %{ @type codebase_info :: %{
canonical_id: String.t(), canonical_id: String.t(),
display_name: String.t(), display_name: String.t(),
workspace_path: String.t(), workspace_path: String.t(),
repository_url: String.t() | nil, repository_url: String.t() | nil,
git_remote: String.t() | nil, git_remote: String.t() | nil,
branch: String.t() | nil, branch: String.t() | nil,
commit_hash: String.t() | nil, commit_hash: String.t() | nil,
identification_method: :git_remote | :git_local | :folder_name | :custom identification_method: :git_remote | :git_local | :folder_name | :custom
} }
@doc """ @doc """
Identify a codebase from a workspace path, generating a canonical ID. Identify a codebase from a workspace path, generating a canonical ID.
@@ -56,6 +56,7 @@ defmodule AgentCoordinator.CodebaseIdentifier do
} }
""" """
def identify_codebase(workspace_path, opts \\ []) def identify_codebase(workspace_path, opts \\ [])
def identify_codebase(nil, opts) do def identify_codebase(nil, opts) do
custom_id = Keyword.get(opts, :custom_id, "default") custom_id = Keyword.get(opts, :custom_id, "default")
build_custom_codebase_info(nil, custom_id) build_custom_codebase_info(nil, custom_id)
@@ -128,15 +129,16 @@ defmodule AgentCoordinator.CodebaseIdentifier do
defp identify_git_codebase(workspace_path) do defp identify_git_codebase(workspace_path) do
with {:ok, git_info} <- get_git_info(workspace_path) do with {:ok, git_info} <- get_git_info(workspace_path) do
canonical_id = case git_info.remote_url do canonical_id =
nil -> case git_info.remote_url do
# Local git repo without remote nil ->
"git-local:#{git_info.repo_name}" # Local git repo without remote
"git-local:#{git_info.repo_name}"
remote_url -> remote_url ->
# Extract canonical identifier from remote URL # Extract canonical identifier from remote URL
extract_canonical_from_remote(remote_url) extract_canonical_from_remote(remote_url)
end end
%{ %{
canonical_id: canonical_id, canonical_id: canonical_id,
@@ -183,6 +185,7 @@ defmodule AgentCoordinator.CodebaseIdentifier do
end end
defp git_repository?(workspace_path) when is_nil(workspace_path), do: false defp git_repository?(workspace_path) when is_nil(workspace_path), do: false
defp git_repository?(workspace_path) do defp git_repository?(workspace_path) do
File.exists?(Path.join(workspace_path, ".git")) File.exists?(Path.join(workspace_path, ".git"))
end end
@@ -201,26 +204,34 @@ defmodule AgentCoordinator.CodebaseIdentifier do
commit_hash = String.trim(commit_hash) commit_hash = String.trim(commit_hash)
# Try to get remote URL # Try to get remote URL
{remote_info, _remote_result_use_me?} = case System.cmd("git", ["remote", "-v"], cd: workspace_path) do {remote_info, _remote_result_use_me?} =
{output, 0} when output != "" -> case System.cmd("git", ["remote", "-v"], cd: workspace_path) do
# Parse remote output to extract origin URL {output, 0} when output != "" ->
lines = String.split(String.trim(output), "\n") # Parse remote output to extract origin URL
origin_line = Enum.find(lines, fn line -> lines = String.split(String.trim(output), "\n")
String.starts_with?(line, "origin") and String.contains?(line, "(fetch)")
end)
case origin_line do origin_line =
nil -> {nil, :no_origin} Enum.find(lines, fn line ->
line -> String.starts_with?(line, "origin") and String.contains?(line, "(fetch)")
# Extract URL from "origin <url> (fetch)" end)
url = line
|> String.split()
|> Enum.at(1)
{url, :ok}
end
_ -> {nil, :no_remotes} case origin_line do
end nil ->
{nil, :no_origin}
line ->
# Extract URL from "origin <url> (fetch)"
url =
line
|> String.split()
|> Enum.at(1)
{url, :ok}
end
_ ->
{nil, :no_remotes}
end
git_info = %{ git_info = %{
repo_name: repo_name, repo_name: repo_name,
@@ -267,6 +278,7 @@ defmodule AgentCoordinator.CodebaseIdentifier do
case Regex.run(regex, url) do case Regex.run(regex, url) do
[_, owner, repo] -> [_, owner, repo] ->
"github.com/#{owner}/#{repo}" "github.com/#{owner}/#{repo}"
_ -> _ ->
"github.com/unknown" "github.com/unknown"
end end
@@ -279,6 +291,7 @@ defmodule AgentCoordinator.CodebaseIdentifier do
case Regex.run(regex, url) do case Regex.run(regex, url) do
[_, owner, repo] -> [_, owner, repo] ->
"gitlab.com/#{owner}/#{repo}" "gitlab.com/#{owner}/#{repo}"
_ -> _ ->
"gitlab.com/unknown" "gitlab.com/unknown"
end end

View File

@@ -14,11 +14,11 @@ defmodule AgentCoordinator.HttpInterface do
require Logger require Logger
alias AgentCoordinator.{MCPServer, ToolFilter, SessionManager} alias AgentCoordinator.{MCPServer, ToolFilter, SessionManager}
plug Plug.Logger plug(Plug.Logger)
plug :match plug(:match)
plug Plug.Parsers, parsers: [:json], json_decoder: Jason plug(Plug.Parsers, parsers: [:json], json_decoder: Jason)
plug :put_cors_headers plug(:put_cors_headers)
plug :dispatch plug(:dispatch)
@doc """ @doc """
Start the HTTP server on the specified port. Start the HTTP server on the specified port.
@@ -109,9 +109,10 @@ defmodule AgentCoordinator.HttpInterface do
all_tools = MCPServer.get_tools() all_tools = MCPServer.get_tools()
filtered_tools = ToolFilter.filter_tools(all_tools, context) filtered_tools = ToolFilter.filter_tools(all_tools, context)
tool_allowed = Enum.any?(filtered_tools, fn tool -> tool_allowed =
Map.get(tool, "name") == tool_name Enum.any?(filtered_tools, fn tool ->
end) Map.get(tool, "name") == tool_name
end)
if not tool_allowed do if not tool_allowed do
send_json_response(conn, 403, %{ send_json_response(conn, 403, %{
@@ -159,6 +160,7 @@ defmodule AgentCoordinator.HttpInterface do
unexpected -> unexpected ->
IO.puts(:stderr, "Unexpected MCP response: #{inspect(unexpected)}") IO.puts(:stderr, "Unexpected MCP response: #{inspect(unexpected)}")
send_json_response(conn, 500, %{ send_json_response(conn, 500, %{
error: %{ error: %{
code: -32603, code: -32603,
@@ -187,6 +189,7 @@ defmodule AgentCoordinator.HttpInterface do
case method do case method do
"tools/call" -> "tools/call" ->
tool_name = get_in(enhanced_request, ["params", "name"]) tool_name = get_in(enhanced_request, ["params", "name"])
if tool_allowed_for_context?(tool_name, context) do if tool_allowed_for_context?(tool_name, context) do
execute_mcp_request(conn, enhanced_request, context) execute_mcp_request(conn, enhanced_request, context)
else else
@@ -275,20 +278,25 @@ defmodule AgentCoordinator.HttpInterface do
case validate_session_for_method("stream/subscribe", conn, context) do case validate_session_for_method("stream/subscribe", conn, context) do
{:ok, session_info} -> {:ok, session_info} ->
# Set up SSE headers # Set up SSE headers
conn = conn conn =
|> put_resp_content_type("text/event-stream") conn
|> put_mcp_headers() |> put_resp_content_type("text/event-stream")
|> put_resp_header("cache-control", "no-cache") |> put_mcp_headers()
|> put_resp_header("connection", "keep-alive") |> put_resp_header("cache-control", "no-cache")
|> put_resp_header("access-control-allow-credentials", "true") |> put_resp_header("connection", "keep-alive")
|> send_chunked(200) |> put_resp_header("access-control-allow-credentials", "true")
|> send_chunked(200)
# Send initial connection event # Send initial connection event
{:ok, conn} = chunk(conn, format_sse_event("connected", %{ {:ok, conn} =
session_id: Map.get(session_info, :agent_id, "anonymous"), chunk(
protocol_version: "2025-06-18", conn,
timestamp: DateTime.utc_now() |> DateTime.to_iso8601() format_sse_event("connected", %{
})) session_id: Map.get(session_info, :agent_id, "anonymous"),
protocol_version: "2025-06-18",
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
})
)
# Start streaming loop # Start streaming loop
stream_mcp_events(conn, session_info, context) stream_mcp_events(conn, session_info, context)
@@ -307,10 +315,15 @@ defmodule AgentCoordinator.HttpInterface do
# Send periodic heartbeat for now # Send periodic heartbeat for now
try do try do
:timer.sleep(1000) :timer.sleep(1000)
{:ok, conn} = chunk(conn, format_sse_event("heartbeat", %{
timestamp: DateTime.utc_now() |> DateTime.to_iso8601(), {:ok, conn} =
session_id: Map.get(session_info, :agent_id, "anonymous") chunk(
})) conn,
format_sse_event("heartbeat", %{
timestamp: DateTime.utc_now() |> DateTime.to_iso8601(),
session_id: Map.get(session_info, :agent_id, "anonymous")
})
)
# Continue streaming (this would be event-driven in production) # Continue streaming (this would be event-driven in production)
stream_mcp_events(conn, session_info, context) stream_mcp_events(conn, session_info, context)
@@ -347,10 +360,11 @@ defmodule AgentCoordinator.HttpInterface do
defp cowboy_dispatch do defp cowboy_dispatch do
[ [
{:_, [ {:_,
{"/mcp/ws", AgentCoordinator.WebSocketHandler, []}, [
{:_, Plug.Cowboy.Handler, {__MODULE__, []}} {"/mcp/ws", AgentCoordinator.WebSocketHandler, []},
]} {:_, Plug.Cowboy.Handler, {__MODULE__, []}}
]}
] ]
end end
@@ -379,8 +393,10 @@ defmodule AgentCoordinator.HttpInterface do
cond do cond do
forwarded_for -> forwarded_for ->
forwarded_for |> String.split(",") |> List.first() |> String.trim() forwarded_for |> String.split(",") |> List.first() |> String.trim()
real_ip -> real_ip ->
real_ip real_ip
true -> true ->
conn.remote_ip |> :inet.ntoa() |> to_string() conn.remote_ip |> :inet.ntoa() |> to_string()
end end
@@ -394,27 +410,37 @@ defmodule AgentCoordinator.HttpInterface do
conn conn
|> put_resp_header("access-control-allow-origin", allowed_origin) |> put_resp_header("access-control-allow-origin", allowed_origin)
|> put_resp_header("access-control-allow-methods", "GET, POST, OPTIONS") |> put_resp_header("access-control-allow-methods", "GET, POST, OPTIONS")
|> put_resp_header("access-control-allow-headers", "content-type, authorization, mcp-session-id, mcp-protocol-version, x-session-id") |> put_resp_header(
"access-control-allow-headers",
"content-type, authorization, mcp-session-id, mcp-protocol-version, x-session-id"
)
|> put_resp_header("access-control-expose-headers", "mcp-protocol-version, server") |> put_resp_header("access-control-expose-headers", "mcp-protocol-version, server")
|> put_resp_header("access-control-max-age", "86400") |> put_resp_header("access-control-max-age", "86400")
end end
defp validate_origin(nil), do: "*" # No origin header (direct API calls) # No origin header (direct API calls)
defp validate_origin(nil), do: "*"
defp validate_origin(origin) do defp validate_origin(origin) do
# Allow localhost and development origins # Allow localhost and development origins
case URI.parse(origin) do case URI.parse(origin) do
%URI{host: host} when host in ["localhost", "127.0.0.1", "::1"] -> origin %URI{host: host} when host in ["localhost", "127.0.0.1", "::1"] ->
origin
%URI{host: host} when is_binary(host) -> %URI{host: host} when is_binary(host) ->
# Allow HTTPS origins and known development domains # Allow HTTPS origins and known development domains
if String.starts_with?(origin, "https://") or if String.starts_with?(origin, "https://") or
String.contains?(host, ["localhost", "127.0.0.1", "dev", "local"]) do String.contains?(host, ["localhost", "127.0.0.1", "dev", "local"]) do
origin origin
else else
# For production, be more restrictive # For production, be more restrictive
IO.puts(:stderr, "Potentially unsafe origin: #{origin}") IO.puts(:stderr, "Potentially unsafe origin: #{origin}")
"*" # Fallback for now, could be more restrictive # Fallback for now, could be more restrictive
"*"
end end
_ -> "*"
_ ->
"*"
end end
end end
@@ -434,9 +460,10 @@ defmodule AgentCoordinator.HttpInterface do
defp validate_mcp_request(params) when is_map(params) do defp validate_mcp_request(params) when is_map(params) do
required_fields = ["jsonrpc", "method"] required_fields = ["jsonrpc", "method"]
missing_fields = Enum.filter(required_fields, fn field -> missing_fields =
not Map.has_key?(params, field) Enum.filter(required_fields, fn field ->
end) not Map.has_key?(params, field)
end)
cond do cond do
not Enum.empty?(missing_fields) -> not Enum.empty?(missing_fields) ->
@@ -460,15 +487,16 @@ defmodule AgentCoordinator.HttpInterface do
{session_id, session_info} = get_session_info(conn) {session_id, session_info} = get_session_info(conn)
# Add context metadata to request params # Add context metadata to request params
enhanced_params = Map.get(mcp_request, "params", %{}) enhanced_params =
|> Map.put("_session_id", session_id) Map.get(mcp_request, "params", %{})
|> Map.put("_session_info", session_info) |> Map.put("_session_id", session_id)
|> Map.put("_client_context", %{ |> Map.put("_session_info", session_info)
connection_type: context.connection_type, |> Map.put("_client_context", %{
security_level: context.security_level, connection_type: context.connection_type,
remote_ip: get_remote_ip(conn), security_level: context.security_level,
user_agent: context.user_agent remote_ip: get_remote_ip(conn),
}) user_agent: context.user_agent
})
Map.put(mcp_request, "params", enhanced_params) Map.put(mcp_request, "params", enhanced_params)
end end
@@ -479,17 +507,21 @@ defmodule AgentCoordinator.HttpInterface do
[session_token] when byte_size(session_token) > 0 -> [session_token] when byte_size(session_token) > 0 ->
case SessionManager.validate_session(session_token) do case SessionManager.validate_session(session_token) do
{:ok, session_info} -> {:ok, session_info} ->
{session_info.agent_id, %{ {session_info.agent_id,
token: session_token, %{
agent_id: session_info.agent_id, token: session_token,
capabilities: session_info.capabilities, agent_id: session_info.agent_id,
expires_at: session_info.expires_at, capabilities: session_info.capabilities,
validated: true expires_at: session_info.expires_at,
}} validated: true
}}
{:error, reason} -> {:error, reason} ->
IO.puts(:stderr, "Invalid MCP session token: #{reason}") IO.puts(:stderr, "Invalid MCP session token: #{reason}")
# Fall back to generating anonymous session # Fall back to generating anonymous session
anonymous_id = "http_anonymous_" <> (:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)) anonymous_id =
"http_anonymous_" <> (:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower))
{anonymous_id, %{validated: false, reason: reason}} {anonymous_id, %{validated: false, reason: reason}}
end end
@@ -498,9 +530,12 @@ defmodule AgentCoordinator.HttpInterface do
case get_req_header(conn, "x-session-id") do case get_req_header(conn, "x-session-id") do
[session_id] when byte_size(session_id) > 0 -> [session_id] when byte_size(session_id) > 0 ->
{session_id, %{validated: false, legacy: true}} {session_id, %{validated: false, legacy: true}}
_ -> _ ->
# No session header, generate anonymous session # No session header, generate anonymous session
anonymous_id = "http_anonymous_" <> (:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)) anonymous_id =
"http_anonymous_" <> (:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower))
{anonymous_id, %{validated: false, anonymous: true}} {anonymous_id, %{validated: false, anonymous: true}}
end end
end end
@@ -512,27 +547,31 @@ defmodule AgentCoordinator.HttpInterface do
case Map.get(session_info, :validated, false) do case Map.get(session_info, :validated, false) do
true -> true ->
{:ok, session_info} {:ok, session_info}
false -> false ->
reason = Map.get(session_info, :reason, "Session not authenticated") reason = Map.get(session_info, :reason, "Session not authenticated")
{:error, %{
code: -32001, {:error,
message: "Authentication required", %{
data: %{reason: reason} code: -32001,
}} message: "Authentication required",
data: %{reason: reason}
}}
end end
end end
defp validate_session_for_method(method, conn, context) do defp validate_session_for_method(method, conn, context) do
# Define which methods require authenticated sessions # Define which methods require authenticated sessions
authenticated_methods = MapSet.new([ authenticated_methods =
"agents/register", MapSet.new([
"agents/unregister", "agents/register",
"agents/heartbeat", "agents/unregister",
"tasks/create", "agents/heartbeat",
"tasks/complete", "tasks/create",
"codebase/register", "tasks/complete",
"stream/subscribe" "codebase/register",
]) "stream/subscribe"
])
if MapSet.member?(authenticated_methods, method) do if MapSet.member?(authenticated_methods, method) do
require_authenticated_session(conn, context) require_authenticated_session(conn, context)
@@ -560,6 +599,7 @@ defmodule AgentCoordinator.HttpInterface do
unexpected -> unexpected ->
IO.puts(:stderr, "Unexpected MCP response: #{inspect(unexpected)}") IO.puts(:stderr, "Unexpected MCP response: #{inspect(unexpected)}")
send_json_response(conn, 500, %{ send_json_response(conn, 500, %{
jsonrpc: "2.0", jsonrpc: "2.0",
id: Map.get(mcp_request, "id"), id: Map.get(mcp_request, "id"),

View File

@@ -102,7 +102,10 @@ defmodule AgentCoordinator.InterfaceManager do
metrics: initialize_metrics() metrics: initialize_metrics()
} }
IO.puts(:stderr, "Interface Manager starting with config: #{inspect(config.enabled_interfaces)}") IO.puts(
:stderr,
"Interface Manager starting with config: #{inspect(config.enabled_interfaces)}"
)
# Start enabled interfaces # Start enabled interfaces
{:ok, state, {:continue, :start_interfaces}} {:ok, state, {:continue, :start_interfaces}}
@@ -111,17 +114,18 @@ defmodule AgentCoordinator.InterfaceManager do
@impl GenServer @impl GenServer
def handle_continue(:start_interfaces, state) do def handle_continue(:start_interfaces, state) do
# Start each enabled interface # Start each enabled interface
updated_state = Enum.reduce(state.config.enabled_interfaces, state, fn interface_type, acc -> updated_state =
case start_interface_server(interface_type, state.config, acc) do Enum.reduce(state.config.enabled_interfaces, state, fn interface_type, acc ->
{:ok, interface_info} -> case start_interface_server(interface_type, state.config, acc) do
IO.puts(:stderr, "Started #{interface_type} interface") {:ok, interface_info} ->
%{acc | interfaces: Map.put(acc.interfaces, interface_type, interface_info)} IO.puts(:stderr, "Started #{interface_type} interface")
%{acc | interfaces: Map.put(acc.interfaces, interface_type, interface_info)}
{:error, reason} -> {:error, reason} ->
IO.puts(:stderr, "Failed to start #{interface_type} interface: #{reason}") IO.puts(:stderr, "Failed to start #{interface_type} interface: #{reason}")
acc acc
end end
end) end)
{:noreply, updated_state} {:noreply, updated_state}
end end
@@ -224,9 +228,11 @@ defmodule AgentCoordinator.InterfaceManager do
@impl GenServer @impl GenServer
def handle_call(:get_metrics, _from, state) do def handle_call(:get_metrics, _from, state) do
# Collect metrics from all running interfaces # Collect metrics from all running interfaces
interface_metrics = Enum.map(state.interfaces, fn {interface_type, interface_info} -> interface_metrics =
{interface_type, get_interface_metrics(interface_type, interface_info)} Enum.map(state.interfaces, fn {interface_type, interface_info} ->
end) |> Enum.into(%{}) {interface_type, get_interface_metrics(interface_type, interface_info)}
end)
|> Enum.into(%{})
metrics = %{ metrics = %{
interfaces: interface_metrics, interfaces: interface_metrics,
@@ -369,11 +375,21 @@ defmodule AgentCoordinator.InterfaceManager do
interface_mode = System.get_env("MCP_INTERFACE_MODE", "stdio") interface_mode = System.get_env("MCP_INTERFACE_MODE", "stdio")
case interface_mode do case interface_mode do
"stdio" -> [:stdio] "stdio" ->
"http" -> [:http] [:stdio]
"websocket" -> [:websocket]
"all" -> [:stdio, :http, :websocket] "http" ->
"remote" -> [:http, :websocket] [:http]
"websocket" ->
[:websocket]
"all" ->
[:stdio, :http, :websocket]
"remote" ->
[:http, :websocket]
_ -> _ ->
# Check for comma-separated list # Check for comma-separated list
if String.contains?(interface_mode, ",") do if String.contains?(interface_mode, ",") do
@@ -400,14 +416,17 @@ defmodule AgentCoordinator.InterfaceManager do
end end
defp update_http_config_from_env(config) do defp update_http_config_from_env(config) do
config = case System.get_env("MCP_HTTP_PORT") do config =
nil -> config case System.get_env("MCP_HTTP_PORT") do
port_str -> nil ->
case Integer.parse(port_str) do config
{port, ""} -> put_in(config, [:http, :port], port)
_ -> config port_str ->
end case Integer.parse(port_str) do
end {port, ""} -> put_in(config, [:http, :port], port)
_ -> config
end
end
case System.get_env("MCP_HTTP_HOST") do case System.get_env("MCP_HTTP_HOST") do
nil -> config nil -> config
@@ -472,7 +491,8 @@ defmodule AgentCoordinator.InterfaceManager do
# WebSocket is handled by the HTTP server, so just mark it as enabled # WebSocket is handled by the HTTP server, so just mark it as enabled
interface_info = %{ interface_info = %{
type: :websocket, type: :websocket,
pid: :embedded, # Embedded in HTTP server # Embedded in HTTP server
pid: :embedded,
started_at: DateTime.utc_now(), started_at: DateTime.utc_now(),
config: config.websocket config: config.websocket
} }
@@ -583,16 +603,18 @@ defmodule AgentCoordinator.InterfaceManager do
"message" => "Parse error: #{Exception.message(e)}" "message" => "Parse error: #{Exception.message(e)}"
} }
} }
IO.puts(Jason.encode!(error_response)) IO.puts(Jason.encode!(error_response))
e -> e ->
# Try to get the ID from the malformed request # Try to get the ID from the malformed request
id = try do id =
partial = Jason.decode!(json_line) try do
Map.get(partial, "id") partial = Jason.decode!(json_line)
rescue Map.get(partial, "id")
_ -> nil rescue
end _ -> nil
end
error_response = %{ error_response = %{
"jsonrpc" => "2.0", "jsonrpc" => "2.0",
@@ -602,6 +624,7 @@ defmodule AgentCoordinator.InterfaceManager do
"message" => "Internal error: #{Exception.message(e)}" "message" => "Internal error: #{Exception.message(e)}"
} }
} }
IO.puts(Jason.encode!(error_response)) IO.puts(Jason.encode!(error_response))
end end
end end
@@ -628,7 +651,8 @@ defmodule AgentCoordinator.InterfaceManager do
defp get_interface_metrics(:websocket, interface_info) do defp get_interface_metrics(:websocket, interface_info) do
%{ %{
type: :websocket, type: :websocket,
status: :running, # Embedded in HTTP server # Embedded in HTTP server
status: :running,
uptime: DateTime.diff(DateTime.utc_now(), interface_info.started_at, :second), uptime: DateTime.diff(DateTime.utc_now(), interface_info.started_at, :second),
embedded: true embedded: true
} }
@@ -678,17 +702,17 @@ defmodule AgentCoordinator.InterfaceManager do
# Check if running in Docker environment # Check if running in Docker environment
defp docker_environment? do defp docker_environment? do
# Check common Docker environment indicators # Check common Docker environment indicators
System.get_env("DOCKER_CONTAINER") != nil or
System.get_env("container") != nil or
System.get_env("DOCKERIZED") != nil or
File.exists?("/.dockerenv") or
File.exists?("/proc/1/cgroup") and
(File.read!("/proc/1/cgroup") |> String.contains?("docker")) or
String.contains?(to_string(System.get_env("PATH", "")), "/app/") or
# Check if we're running under a container init system # Check if we're running under a container init system
case File.read("/proc/1/comm") do System.get_env("DOCKER_CONTAINER") != nil or
{:ok, comm} -> String.trim(comm) in ["bash", "sh", "docker-init", "tini"] System.get_env("container") != nil or
_ -> false System.get_env("DOCKERIZED") != nil or
end File.exists?("/.dockerenv") or
(File.exists?("/proc/1/cgroup") and
File.read!("/proc/1/cgroup") |> String.contains?("docker")) or
String.contains?(to_string(System.get_env("PATH", "")), "/app/") or
case File.read("/proc/1/comm") do
{:ok, comm} -> String.trim(comm) in ["bash", "sh", "docker-init", "tini"]
_ -> false
end
end end
end end

View File

@@ -11,7 +11,18 @@ defmodule AgentCoordinator.MCPServer do
use GenServer use GenServer
require Logger require Logger
alias AgentCoordinator.{TaskRegistry, Inbox, Agent, Task, CodebaseRegistry, VSCodeToolProvider, ToolFilter, SessionManager, ActivityTracker}
alias AgentCoordinator.{
TaskRegistry,
Inbox,
Agent,
Task,
CodebaseRegistry,
VSCodeToolProvider,
ToolFilter,
SessionManager,
ActivityTracker
}
# State for tracking external servers and agent sessions # State for tracking external servers and agent sessions
defstruct [ defstruct [
@@ -26,7 +37,8 @@ defmodule AgentCoordinator.MCPServer do
@mcp_tools [ @mcp_tools [
%{ %{
"name" => "register_agent", "name" => "register_agent",
"description" => "Register a new agent with the coordination system. Each agent must choose a unique identifier (e.g., 'Green Platypus', 'Blue Koala') and include their agent_id in all subsequent tool calls to identify themselves.", "description" =>
"Register a new agent with the coordination system. Each agent must choose a unique identifier (e.g., 'Green Platypus', 'Blue Koala') and include their agent_id in all subsequent tool calls to identify themselves.",
"inputSchema" => %{ "inputSchema" => %{
"type" => "object", "type" => "object",
"properties" => %{ "properties" => %{
@@ -38,7 +50,11 @@ defmodule AgentCoordinator.MCPServer do
"enum" => ["coding", "testing", "documentation", "analysis", "review"] "enum" => ["coding", "testing", "documentation", "analysis", "review"]
} }
}, },
"codebase_id" => %{"type" => "string", "description" => "If the project is found locally on the machine, use the name of the directory in which you are currently at (.). If it is remote, use the git registered codebase ID, if it is a multicodebase project, and there is no apparently folder to base as the rootmost -- ask."}, "codebase_id" => %{
"type" => "string",
"description" =>
"If the project is found locally on the machine, use the name of the directory in which you are currently at (.). If it is remote, use the git registered codebase ID, if it is a multicodebase project, and there is no apparently folder to base as the rootmost -- ask."
},
"workspace_path" => %{"type" => "string"}, "workspace_path" => %{"type" => "string"},
"cross_codebase_capable" => %{"type" => "boolean"} "cross_codebase_capable" => %{"type" => "boolean"}
}, },
@@ -71,7 +87,11 @@ defmodule AgentCoordinator.MCPServer do
"title" => %{"type" => "string"}, "title" => %{"type" => "string"},
"description" => %{"type" => "string"}, "description" => %{"type" => "string"},
"priority" => %{"type" => "string", "enum" => ["low", "normal", "high", "urgent"]}, "priority" => %{"type" => "string", "enum" => ["low", "normal", "high", "urgent"]},
"codebase_id" => %{"type" => "string", "description" => "If the project is found locally on the machine, use the name of the directory in which you are currently at (.). If it is remote, use the git registered codebase ID, if it is a multicodebase project, and there is no apparently folder to base as the rootmost -- ask."}, "codebase_id" => %{
"type" => "string",
"description" =>
"If the project is found locally on the machine, use the name of the directory in which you are currently at (.). If it is remote, use the git registered codebase ID, if it is a multicodebase project, and there is no apparently folder to base as the rootmost -- ask."
},
"file_paths" => %{"type" => "array", "items" => %{"type" => "string"}}, "file_paths" => %{"type" => "array", "items" => %{"type" => "string"}},
"required_capabilities" => %{ "required_capabilities" => %{
"type" => "array", "type" => "array",
@@ -110,7 +130,13 @@ defmodule AgentCoordinator.MCPServer do
"enum" => ["sequential", "parallel", "leader_follower"] "enum" => ["sequential", "parallel", "leader_follower"]
} }
}, },
"required" => ["agent_id", "title", "description", "primary_codebase_id", "affected_codebases"] "required" => [
"agent_id",
"title",
"description",
"primary_codebase_id",
"affected_codebases"
]
} }
}, },
%{ %{
@@ -334,7 +360,8 @@ defmodule AgentCoordinator.MCPServer do
}, },
%{ %{
"name" => "discover_codebase_info", "name" => "discover_codebase_info",
"description" => "Intelligently discover codebase information from workspace path, including git repository details, canonical ID generation, and project identification.", "description" =>
"Intelligently discover codebase information from workspace path, including git repository details, canonical ID generation, and project identification.",
"inputSchema" => %{ "inputSchema" => %{
"type" => "object", "type" => "object",
"properties" => %{ "properties" => %{
@@ -422,8 +449,9 @@ defmodule AgentCoordinator.MCPServer do
tool_name = Map.get(request, "params", %{}) |> Map.get("name") tool_name = Map.get(request, "params", %{}) |> Map.get("name")
# Allow certain MCP system calls and register_agent to proceed without agent_id # Allow certain MCP system calls and register_agent to proceed without agent_id
allowed_without_agent = method in ["initialize", "tools/list", "notifications/initialized"] or allowed_without_agent =
(method == "tools/call" and tool_name == "register_agent") method in ["initialize", "tools/list", "notifications/initialized"] or
(method == "tools/call" and tool_name == "register_agent")
IO.puts(:stderr, "#{method} #{inspect(request)} #{tool_name}") IO.puts(:stderr, "#{method} #{inspect(request)} #{tool_name}")
@@ -434,6 +462,7 @@ defmodule AgentCoordinator.MCPServer do
else else
# Log the rejected call for debugging # Log the rejected call for debugging
IO.puts(:stderr, "Rejected call without agent_id: method=#{method}, tool=#{tool_name}") IO.puts(:stderr, "Rejected call without agent_id: method=#{method}, tool=#{tool_name}")
error_response = %{ error_response = %{
"jsonrpc" => "2.0", "jsonrpc" => "2.0",
"id" => Map.get(request, "id"), "id" => Map.get(request, "id"),
@@ -442,6 +471,7 @@ defmodule AgentCoordinator.MCPServer do
"message" => error_message "message" => error_message
} }
} }
{:reply, error_response, state} {:reply, error_response, state}
end end
@@ -466,15 +496,17 @@ defmodule AgentCoordinator.MCPServer do
update_session_activity(agent_context[:agent_id]) update_session_activity(agent_context[:agent_id])
# Add heartbeat metadata to successful responses # Add heartbeat metadata to successful responses
enhanced_response = case response do enhanced_response =
%{"result" => _} = success -> case response do
Map.put(success, "_heartbeat_metadata", %{ %{"result" => _} = success ->
agent_id: agent_context[:agent_id], Map.put(success, "_heartbeat_metadata", %{
timestamp: DateTime.utc_now() agent_id: agent_context[:agent_id],
}) timestamp: DateTime.utc_now()
error_result -> })
error_result
end error_result ->
error_result
end
{:reply, enhanced_response, state} {:reply, enhanced_response, state}
else else
@@ -487,10 +519,12 @@ defmodule AgentCoordinator.MCPServer do
all_tools = get_all_unified_tools_from_state(state) all_tools = get_all_unified_tools_from_state(state)
# Apply tool filtering if client context is provided # Apply tool filtering if client context is provided
filtered_tools = case client_context do filtered_tools =
nil -> all_tools # No filtering for nil context (backward compatibility) case client_context do
context -> ToolFilter.filter_tools(all_tools, context) # No filtering for nil context (backward compatibility)
end nil -> all_tools
context -> ToolFilter.filter_tools(all_tools, context)
end
{:reply, filtered_tools, state} {:reply, filtered_tools, state}
end end
@@ -624,8 +658,12 @@ defmodule AgentCoordinator.MCPServer do
# Start inbox for the agent (handle already started case) # Start inbox for the agent (handle already started case)
case Inbox.start_link(agent.id) do case Inbox.start_link(agent.id) do
{:ok, _pid} -> :ok {:ok, _pid} ->
{:error, {:already_started, _pid}} -> :ok :ok
{:error, {:already_started, _pid}} ->
:ok
{:error, reason} -> {:error, reason} ->
IO.puts(:stderr, "Failed to start inbox for agent #{agent.id}: #{inspect(reason)}") IO.puts(:stderr, "Failed to start inbox for agent #{agent.id}: #{inspect(reason)}")
:ok :ok
@@ -645,13 +683,14 @@ defmodule AgentCoordinator.MCPServer do
# Track the session if we have caller info # Track the session if we have caller info
track_agent_session(agent.id, name, capabilities) track_agent_session(agent.id, name, capabilities)
{:ok, %{ {:ok,
agent_id: agent.id, %{
codebase_id: agent.codebase_id, agent_id: agent.id,
status: "registered", codebase_id: agent.codebase_id,
session_token: session_token, status: "registered",
expires_at: DateTime.add(DateTime.utc_now(), 60, :minute) |> DateTime.to_iso8601() session_token: session_token,
}} expires_at: DateTime.add(DateTime.utc_now(), 60, :minute) |> DateTime.to_iso8601()
}}
{:error, reason} -> {:error, reason} ->
IO.puts(:stderr, "Failed to create session for agent #{agent.id}: #{inspect(reason)}") IO.puts(:stderr, "Failed to create session for agent #{agent.id}: #{inspect(reason)}")
@@ -1136,7 +1175,9 @@ defmodule AgentCoordinator.MCPServer do
# NEW: Codebase discovery function # NEW: Codebase discovery function
defp discover_codebase_info(%{"agent_id" => agent_id, "workspace_path" => workspace_path} = args) do defp discover_codebase_info(
%{"agent_id" => agent_id, "workspace_path" => workspace_path} = args
) do
custom_id = Map.get(args, "custom_id") custom_id = Map.get(args, "custom_id")
# Use the CodebaseIdentifier to analyze the workspace # Use the CodebaseIdentifier to analyze the workspace
@@ -1145,32 +1186,37 @@ defmodule AgentCoordinator.MCPServer do
case AgentCoordinator.CodebaseIdentifier.identify_codebase(workspace_path, opts) do case AgentCoordinator.CodebaseIdentifier.identify_codebase(workspace_path, opts) do
codebase_info -> codebase_info ->
# Also check if this codebase is already registered # Also check if this codebase is already registered
existing_codebase = case CodebaseRegistry.get_codebase(codebase_info.canonical_id) do existing_codebase =
{:ok, codebase} -> codebase case CodebaseRegistry.get_codebase(codebase_info.canonical_id) do
{:error, :not_found} -> nil {:ok, codebase} -> codebase
end {:error, :not_found} -> nil
end
# Check for other agents working on same codebase # Check for other agents working on same codebase
agents = TaskRegistry.list_agents() agents = TaskRegistry.list_agents()
related_agents = Enum.filter(agents, fn agent ->
agent.codebase_id == codebase_info.canonical_id and agent.id != agent_id related_agents =
end) Enum.filter(agents, fn agent ->
agent.codebase_id == codebase_info.canonical_id and agent.id != agent_id
end)
response = %{ response = %{
codebase_info: codebase_info, codebase_info: codebase_info,
already_registered: existing_codebase != nil, already_registered: existing_codebase != nil,
existing_codebase: existing_codebase, existing_codebase: existing_codebase,
related_agents: Enum.map(related_agents, fn agent -> related_agents:
%{ Enum.map(related_agents, fn agent ->
agent_id: agent.id, %{
name: agent.name, agent_id: agent.id,
capabilities: agent.capabilities, name: agent.name,
status: agent.status, capabilities: agent.capabilities,
workspace_path: agent.workspace_path, status: agent.status,
online: Agent.is_online?(agent) workspace_path: agent.workspace_path,
} online: Agent.is_online?(agent)
end), }
recommendations: generate_codebase_recommendations(codebase_info, existing_codebase, related_agents) end),
recommendations:
generate_codebase_recommendations(codebase_info, existing_codebase, related_agents)
} }
{:ok, response} {:ok, response}
@@ -1181,26 +1227,39 @@ defmodule AgentCoordinator.MCPServer do
recommendations = [] recommendations = []
# Recommend registration if not already registered # Recommend registration if not already registered
recommendations = if existing_codebase == nil do recommendations =
["Consider registering this codebase with register_codebase for better coordination" | recommendations] if existing_codebase == nil do
else [
recommendations "Consider registering this codebase with register_codebase for better coordination"
end | recommendations
]
else
recommendations
end
# Recommend coordination if other agents are working on same codebase # Recommend coordination if other agents are working on same codebase
recommendations = if length(related_agents) > 0 do recommendations =
agent_names = Enum.map(related_agents, & &1.name) |> Enum.join(", ") if length(related_agents) > 0 do
["Other agents working on this codebase: #{agent_names}. Consider coordination." | recommendations] agent_names = Enum.map(related_agents, & &1.name) |> Enum.join(", ")
else
recommendations [
end "Other agents working on this codebase: #{agent_names}. Consider coordination."
| recommendations
]
else
recommendations
end
# Recommend git setup if local folder without git # Recommend git setup if local folder without git
recommendations = if codebase_info.identification_method == :folder_name do recommendations =
["Consider initializing git repository for better distributed coordination" | recommendations] if codebase_info.identification_method == :folder_name do
else [
recommendations "Consider initializing git repository for better distributed coordination"
end | recommendations
]
else
recommendations
end
Enum.reverse(recommendations) Enum.reverse(recommendations)
end end
@@ -1343,7 +1402,11 @@ defmodule AgentCoordinator.MCPServer do
{:ok, tools} {:ok, tools}
{:ok, unexpected} -> {:ok, unexpected} ->
IO.puts(:stderr, "Unexpected tools response from #{server_info.name}: #{inspect(unexpected)}") IO.puts(
:stderr,
"Unexpected tools response from #{server_info.name}: #{inspect(unexpected)}"
)
{:ok, []} {:ok, []}
{:error, reason} -> {:error, reason} ->
@@ -1369,7 +1432,11 @@ defmodule AgentCoordinator.MCPServer do
{:ok, response} {:ok, response}
{:error, %Jason.DecodeError{} = error} -> {:error, %Jason.DecodeError{} = error} ->
IO.puts(:stderr, "JSON decode error for server #{server_info.name}: #{Exception.message(error)}") IO.puts(
:stderr,
"JSON decode error for server #{server_info.name}: #{Exception.message(error)}"
)
{:error, "JSON decode error: #{Exception.message(error)}"} {:error, "JSON decode error: #{Exception.message(error)}"}
end end
end end
@@ -1379,9 +1446,11 @@ defmodule AgentCoordinator.MCPServer do
receive do receive do
{^port, {:data, data}} -> {^port, {:data, data}} ->
new_acc = acc <> data new_acc = acc <> data
case extract_json_from_data(new_acc) do case extract_json_from_data(new_acc) do
{json_message, _remaining} when json_message != nil -> {json_message, _remaining} when json_message != nil ->
json_message json_message
{nil, remaining} -> {nil, remaining} ->
collect_external_response(port, remaining, timeout) collect_external_response(port, remaining, timeout)
end end
@@ -1403,6 +1472,7 @@ defmodule AgentCoordinator.MCPServer do
case json_lines do case json_lines do
[] -> [] ->
last_line = List.last(lines) || "" last_line = List.last(lines) || ""
if String.trim(last_line) != "" and not String.ends_with?(data, "\n") do if String.trim(last_line) != "" and not String.ends_with?(data, "\n") do
{nil, last_line} {nil, last_line}
else else
@@ -1411,6 +1481,7 @@ defmodule AgentCoordinator.MCPServer do
_ -> _ ->
json_data = Enum.join(json_lines, "\n") json_data = Enum.join(json_lines, "\n")
case Jason.decode(json_data) do case Jason.decode(json_data) do
{:ok, _} -> {json_data, ""} {:ok, _} -> {json_data, ""}
{:error, _} -> {nil, data} {:error, _} -> {nil, data}
@@ -1461,36 +1532,45 @@ defmodule AgentCoordinator.MCPServer do
required = Map.get(input_schema, "required", []) required = Map.get(input_schema, "required", [])
# Add agent_id to properties if not already present # Add agent_id to properties if not already present
updated_properties = Map.put_new(properties, "agent_id", %{ updated_properties =
"type" => "string", Map.put_new(properties, "agent_id", %{
"description" => "ID of the agent making the tool call" "type" => "string",
}) "description" => "ID of the agent making the tool call"
})
# Add agent_id to required fields if not already present # Add agent_id to required fields if not already present
updated_required = if "agent_id" in required do updated_required =
required if "agent_id" in required do
else required
["agent_id" | required] else
end ["agent_id" | required]
end
# Update the input schema # Update the input schema
updated_schema = Map.merge(input_schema, %{ updated_schema =
"properties" => updated_properties, Map.merge(input_schema, %{
"required" => updated_required "properties" => updated_properties,
}) "required" => updated_required
})
# Return the tool with updated schema # Return the tool with updated schema
Map.put(tool, "inputSchema", updated_schema) Map.put(tool, "inputSchema", updated_schema)
rescue rescue
error -> error ->
IO.puts(:stderr, "Failed to transform tool schema for #{inspect(tool)}: #{inspect(error)}") IO.puts(
tool # Return original tool if transformation fails :stderr,
"Failed to transform tool schema for #{inspect(tool)}: #{inspect(error)}"
)
# Return original tool if transformation fails
tool
end end
end end
defp transform_external_tool_schema(tool) do defp transform_external_tool_schema(tool) do
IO.puts(:stderr, "Received non-map tool: #{inspect(tool)}") IO.puts(:stderr, "Received non-map tool: #{inspect(tool)}")
tool # Return as-is if not a map # Return as-is if not a map
tool
end end
defp create_external_pid_file(server_name, os_pid) do defp create_external_pid_file(server_name, os_pid) do
@@ -1540,20 +1620,21 @@ defmodule AgentCoordinator.MCPServer do
# Check if it's a coordinator tool first # Check if it's a coordinator tool first
coordinator_tool_names = Enum.map(@mcp_tools, & &1["name"]) coordinator_tool_names = Enum.map(@mcp_tools, & &1["name"])
result = cond do result =
tool_name in coordinator_tool_names -> cond do
handle_coordinator_tool(tool_name, args) tool_name in coordinator_tool_names ->
handle_coordinator_tool(tool_name, args)
# Check if it's a VS Code tool # Check if it's a VS Code tool
String.starts_with?(tool_name, "vscode_") -> String.starts_with?(tool_name, "vscode_") ->
# Route to VS Code Tool Provider with agent context # Route to VS Code Tool Provider with agent context
context = if agent_id, do: %{agent_id: agent_id}, else: %{} context = if agent_id, do: %{agent_id: agent_id}, else: %{}
VSCodeToolProvider.handle_tool_call(tool_name, args, context) VSCodeToolProvider.handle_tool_call(tool_name, args, context)
true -> true ->
# Try to route to external server # Try to route to external server
route_to_external_server(tool_name, args, state) route_to_external_server(tool_name, args, state)
end end
# Clear agent activity after tool call completes (optional - could keep until next call) # Clear agent activity after tool call completes (optional - could keep until next call)
# if agent_id do # if agent_id do
@@ -1616,9 +1697,10 @@ defmodule AgentCoordinator.MCPServer do
case result do case result do
%{"content" => content} when is_list(content) -> %{"content" => content} when is_list(content) ->
# Return the first text content for simplicity # Return the first text content for simplicity
text_content = Enum.find(content, fn item -> text_content =
Map.get(item, "type") == "text" Enum.find(content, fn item ->
end) Map.get(item, "type") == "text"
end)
if text_content do if text_content do
case Jason.decode(Map.get(text_content, "text", "{}")) do case Jason.decode(Map.get(text_content, "text", "{}")) do
@@ -1672,7 +1754,9 @@ defmodule AgentCoordinator.MCPServer do
defp update_session_activity(agent_id) do defp update_session_activity(agent_id) do
case Process.get({:agent_session, agent_id}) do case Process.get({:agent_session, agent_id}) do
nil -> :ok nil ->
:ok
session_info -> session_info ->
updated_session = %{session_info | last_activity: DateTime.utc_now()} updated_session = %{session_info | last_activity: DateTime.utc_now()}
Process.put({:agent_session, agent_id}, updated_session) Process.put({:agent_session, agent_id}, updated_session)
@@ -1684,7 +1768,8 @@ defmodule AgentCoordinator.MCPServer do
# For system calls, don't require agent_id # For system calls, don't require agent_id
if method in ["initialize", "tools/list", "notifications/initialized"] do if method in ["initialize", "tools/list", "notifications/initialized"] do
%{agent_id: nil} # System call, no agent context needed # System call, no agent context needed
%{agent_id: nil}
else else
# Try to get agent_id from various sources for non-system calls # Try to get agent_id from various sources for non-system calls
cond do cond do
@@ -1697,7 +1782,8 @@ defmodule AgentCoordinator.MCPServer do
# If no explicit agent_id, return error - agents must register first # If no explicit agent_id, return error - agents must register first
true -> true ->
{:error, "Missing agent_id. Agents must register themselves using register_agent before calling other tools."} {:error,
"Missing agent_id. Agents must register themselves using register_agent before calling other tools."}
end end
end end
end end
@@ -1709,11 +1795,14 @@ defmodule AgentCoordinator.MCPServer do
try do try do
case Jason.decode!(File.read!(config_file)) do case Jason.decode!(File.read!(config_file)) do
%{"servers" => servers} -> %{"servers" => servers} ->
normalized_servers = Enum.into(servers, %{}, fn {name, config} -> normalized_servers =
normalized_config = normalize_server_config(config) Enum.into(servers, %{}, fn {name, config} ->
{name, normalized_config} normalized_config = normalize_server_config(config)
end) {name, normalized_config}
end)
%{servers: normalized_servers} %{servers: normalized_servers}
_ -> _ ->
get_default_server_config() get_default_server_config()
end end

View File

@@ -136,7 +136,12 @@ defmodule AgentCoordinator.SessionManager do
session_data -> session_data ->
new_sessions = Map.delete(state.sessions, session_token) new_sessions = Map.delete(state.sessions, session_token)
new_state = %{state | sessions: new_sessions} new_state = %{state | sessions: new_sessions}
IO.puts(:stderr, "Invalidated session #{session_token} for agent #{session_data.agent_id}")
IO.puts(
:stderr,
"Invalidated session #{session_token} for agent #{session_data.agent_id}"
)
{:reply, :ok, new_state} {:reply, :ok, new_state}
end end
end end

View File

@@ -20,22 +20,28 @@ defmodule AgentCoordinator.ToolFilter do
Context information about the client connection. Context information about the client connection.
""" """
defstruct [ defstruct [
:connection_type, # :local, :remote, :web # :local, :remote, :web
:client_info, # Client identification :connection_type,
:capabilities, # Client declared capabilities # Client identification
:security_level, # :trusted, :sandboxed, :restricted :client_info,
:origin, # For web clients, the origin domain # Client declared capabilities
:user_agent # Client user agent string :capabilities,
# :trusted, :sandboxed, :restricted
:security_level,
# For web clients, the origin domain
:origin,
# Client user agent string
:user_agent
] ]
@type client_context :: %__MODULE__{ @type client_context :: %__MODULE__{
connection_type: :local | :remote | :web, connection_type: :local | :remote | :web,
client_info: map(), client_info: map(),
capabilities: [String.t()], capabilities: [String.t()],
security_level: :trusted | :sandboxed | :restricted, security_level: :trusted | :sandboxed | :restricted,
origin: String.t() | nil, origin: String.t() | nil,
user_agent: String.t() | nil user_agent: String.t() | nil
} }
# Tool name patterns that indicate local-only functionality (defined as function to avoid compilation issues) # Tool name patterns that indicate local-only functionality (defined as function to avoid compilation issues)
defp local_only_patterns do defp local_only_patterns do
@@ -198,12 +204,16 @@ defmodule AgentCoordinator.ToolFilter do
description = Map.get(tool, "description", "") description = Map.get(tool, "description", "")
# Check against known local-only tool names # Check against known local-only tool names
name_is_local = tool_name in get_local_only_tool_names() or name_is_local =
Enum.any?(local_only_patterns(), &Regex.match?(&1, tool_name)) tool_name in get_local_only_tool_names() or
Enum.any?(local_only_patterns(), &Regex.match?(&1, tool_name))
# Check description for local-only indicators # Check description for local-only indicators
description_is_local = String.contains?(String.downcase(description), description_is_local =
["filesystem", "file system", "vscode", "terminal", "local file", "directory"]) String.contains?(
String.downcase(description),
["filesystem", "file system", "vscode", "terminal", "local file", "directory"]
)
# Check tool schema for local-only parameters # Check tool schema for local-only parameters
schema_is_local = has_local_only_parameters?(tool) schema_is_local = has_local_only_parameters?(tool)
@@ -214,19 +224,39 @@ defmodule AgentCoordinator.ToolFilter do
defp get_local_only_tool_names do defp get_local_only_tool_names do
[ [
# Filesystem tools # Filesystem tools
"read_file", "write_file", "create_file", "delete_file", "read_file",
"list_directory", "search_files", "move_file", "get_file_info", "write_file",
"list_allowed_directories", "directory_tree", "edit_file", "create_file",
"read_text_file", "read_multiple_files", "read_media_file", "delete_file",
"list_directory",
"search_files",
"move_file",
"get_file_info",
"list_allowed_directories",
"directory_tree",
"edit_file",
"read_text_file",
"read_multiple_files",
"read_media_file",
# VSCode tools # VSCode tools
"vscode_create_file", "vscode_write_file", "vscode_read_file", "vscode_create_file",
"vscode_delete_file", "vscode_list_directory", "vscode_get_active_editor", "vscode_write_file",
"vscode_set_editor_content", "vscode_get_selection", "vscode_set_selection", "vscode_read_file",
"vscode_show_message", "vscode_run_command", "vscode_get_workspace_folders", "vscode_delete_file",
"vscode_list_directory",
"vscode_get_active_editor",
"vscode_set_editor_content",
"vscode_get_selection",
"vscode_set_selection",
"vscode_show_message",
"vscode_run_command",
"vscode_get_workspace_folders",
# Terminal/process tools # Terminal/process tools
"run_in_terminal", "get_terminal_output", "terminal_last_command", "run_in_terminal",
"get_terminal_output",
"terminal_last_command",
"terminal_selection" "terminal_selection"
] ]
end end
@@ -238,8 +268,10 @@ defmodule AgentCoordinator.ToolFilter do
# Look for file path parameters or other local indicators # Look for file path parameters or other local indicators
Enum.any?(properties, fn {param_name, param_schema} -> Enum.any?(properties, fn {param_name, param_schema} ->
param_name in ["path", "filePath", "file_path", "directory", "workspace_path"] or param_name in ["path", "filePath", "file_path", "directory", "workspace_path"] or
String.contains?(Map.get(param_schema, "description", ""), String.contains?(
["file path", "directory", "workspace", "local"]) Map.get(param_schema, "description", ""),
["file path", "directory", "workspace", "local"]
)
end) end)
end end
@@ -251,20 +283,25 @@ defmodule AgentCoordinator.ToolFilter do
Map.get(connection_info, :remote_ip) == "127.0.0.1" -> :local Map.get(connection_info, :remote_ip) == "127.0.0.1" -> :local
Map.get(connection_info, :remote_ip) == "::1" -> :local Map.get(connection_info, :remote_ip) == "::1" -> :local
Map.has_key?(connection_info, :remote_ip) -> :remote Map.has_key?(connection_info, :remote_ip) -> :remote
true -> :local # Default to local for stdio # Default to local for stdio
true -> :local
end end
end end
defp determine_security_level(connection_type, connection_info) do defp determine_security_level(connection_type, connection_info) do
case connection_type do case connection_type do
:local -> :trusted :local ->
:trusted
:remote -> :remote ->
if Map.get(connection_info, :secure, false) do if Map.get(connection_info, :secure, false) do
:sandboxed :sandboxed
else else
:restricted :restricted
end end
:web -> :sandboxed
:web ->
:sandboxed
end end
end end
@@ -278,5 +315,4 @@ defmodule AgentCoordinator.ToolFilter do
tools tools
end end
end end
end end

View File

@@ -21,7 +21,8 @@ defmodule AgentCoordinator.WebSocketHandler do
:connection_info :connection_info
] ]
@heartbeat_interval 30_000 # 30 seconds # 30 seconds
@heartbeat_interval 30_000
@impl WebSock @impl WebSock
def init(opts) do def init(opts) do
@@ -108,7 +109,11 @@ defmodule AgentCoordinator.WebSocketHandler do
@impl WebSock @impl WebSock
def terminate(reason, state) do def terminate(reason, state) do
IO.puts(:stderr, "WebSocket connection terminated: #{state.session_id}, reason: #{inspect(reason)}") IO.puts(
:stderr,
"WebSocket connection terminated: #{state.session_id}, reason: #{inspect(reason)}"
)
cleanup_session(state) cleanup_session(state)
:ok :ok
end end
@@ -183,10 +188,7 @@ defmodule AgentCoordinator.WebSocketHandler do
} }
} }
updated_state = %{state | updated_state = %{state | client_context: client_context, connection_info: connection_info}
client_context: client_context,
connection_info: connection_info
}
{:reply, {:text, Jason.encode!(response)}, updated_state} {:reply, {:text, Jason.encode!(response)}, updated_state}
end end
@@ -246,6 +248,7 @@ defmodule AgentCoordinator.WebSocketHandler do
unexpected -> unexpected ->
IO.puts(:stderr, "Unexpected MCP response: #{inspect(unexpected)}") IO.puts(:stderr, "Unexpected MCP response: #{inspect(unexpected)}")
error_response = %{ error_response = %{
"jsonrpc" => "2.0", "jsonrpc" => "2.0",
"id" => Map.get(message, "id"), "id" => Map.get(message, "id"),
@@ -264,7 +267,8 @@ defmodule AgentCoordinator.WebSocketHandler do
"id" => Map.get(message, "id"), "id" => Map.get(message, "id"),
"error" => %{ "error" => %{
"code" => -32601, "code" => -32601,
"message" => "Tool not available for #{state.client_context.connection_type} clients: #{tool_name}" "message" =>
"Tool not available for #{state.client_context.connection_type} clients: #{tool_name}"
} }
} }
@@ -325,14 +329,15 @@ defmodule AgentCoordinator.WebSocketHandler do
# Add session tracking info to the message # Add session tracking info to the message
params = Map.get(message, "params", %{}) params = Map.get(message, "params", %{})
enhanced_params = params enhanced_params =
|> Map.put("_session_id", state.session_id) params
|> Map.put("_transport", "websocket") |> Map.put("_session_id", state.session_id)
|> Map.put("_client_context", %{ |> Map.put("_transport", "websocket")
connection_type: state.client_context.connection_type, |> Map.put("_client_context", %{
security_level: state.client_context.security_level, connection_type: state.client_context.connection_type,
session_id: state.session_id security_level: state.client_context.security_level,
}) session_id: state.session_id
})
Map.put(message, "params", enhanced_params) Map.put(message, "params", enhanced_params)
end end

View File

@@ -14,16 +14,19 @@ IO.puts("=" |> String.duplicate(60))
try do try do
TaskRegistry.start_link() TaskRegistry.start_link()
rescue rescue
_ -> :ok # Already started # Already started
_ -> :ok
end end
try do try do
MCPServer.start_link() MCPServer.start_link()
rescue rescue
_ -> :ok # Already started # Already started
_ -> :ok
end end
Process.sleep(1000) # Give services time to start # Give services time to start
Process.sleep(1000)
# Test 1: Register two agents # Test 1: Register two agents
IO.puts("\n1⃣ Registering two test agents...") IO.puts("\n1⃣ Registering two test agents...")
@@ -58,23 +61,27 @@ resp1 = MCPServer.handle_mcp_request(agent1_req)
resp2 = MCPServer.handle_mcp_request(agent2_req) resp2 = MCPServer.handle_mcp_request(agent2_req)
# Extract agent IDs # Extract agent IDs
agent1_id = case resp1 do agent1_id =
%{"result" => %{"content" => [%{"text" => text}]}} -> case resp1 do
data = Jason.decode!(text) %{"result" => %{"content" => [%{"text" => text}]}} ->
data["agent_id"] data = Jason.decode!(text)
_ -> data["agent_id"]
IO.puts("❌ Failed to register agent 1: #{inspect(resp1)}")
System.halt(1)
end
agent2_id = case resp2 do _ ->
%{"result" => %{"content" => [%{"text" => text}]}} -> IO.puts("❌ Failed to register agent 1: #{inspect(resp1)}")
data = Jason.decode!(text) System.halt(1)
data["agent_id"] end
_ ->
IO.puts("❌ Failed to register agent 2: #{inspect(resp2)}") agent2_id =
System.halt(1) case resp2 do
end %{"result" => %{"content" => [%{"text" => text}]}} ->
data = Jason.decode!(text)
data["agent_id"]
_ ->
IO.puts("❌ Failed to register agent 2: #{inspect(resp2)}")
System.halt(1)
end
IO.puts("✅ Agent 1 (Alpha Wolf): #{agent1_id}") IO.puts("✅ Agent 1 (Alpha Wolf): #{agent1_id}")
IO.puts("✅ Agent 2 (Beta Tiger): #{agent2_id}") IO.puts("✅ Agent 2 (Beta Tiger): #{agent2_id}")
@@ -219,7 +226,7 @@ history_req1 = %{
history_resp1 = MCPServer.handle_mcp_request(history_req1) history_resp1 = MCPServer.handle_mcp_request(history_req1)
IO.puts("Agent 1 history: #{inspect(history_resp1)}") IO.puts("Agent 1 history: #{inspect(history_resp1)}")
IO.puts("\n" <> "=" |> String.duplicate(60)) IO.puts(("\n" <> "=") |> String.duplicate(60))
IO.puts("🎉 AGENT-SPECIFIC TASK POOLS TEST COMPLETE!") IO.puts("🎉 AGENT-SPECIFIC TASK POOLS TEST COMPLETE!")
IO.puts("✅ Each agent now has their own task pool") IO.puts("✅ Each agent now has their own task pool")
IO.puts("✅ No more task chaos or cross-contamination") IO.puts("✅ No more task chaos or cross-contamination")

View File

@@ -202,6 +202,7 @@ defmodule AgentTaskPoolTest do
%{"result" => %{"content" => [%{"text" => text}]}} -> %{"result" => %{"content" => [%{"text" => text}]}} ->
data = Jason.decode!(text) data = Jason.decode!(text)
data["agent_id"] data["agent_id"]
_ -> _ ->
"unknown" "unknown"
end end

View File

@@ -30,24 +30,27 @@ Process.sleep(1000)
IO.puts("\n2⃣ Creating agent-specific tasks...") IO.puts("\n2⃣ Creating agent-specific tasks...")
# Tasks for Agent 1 # Tasks for Agent 1
task1_agent1 = Task.new("Fix auth bug", "Debug authentication issue", %{ task1_agent1 =
priority: :high, Task.new("Fix auth bug", "Debug authentication issue", %{
assigned_agent: agent1.id, priority: :high,
metadata: %{agent_created: true} assigned_agent: agent1.id,
}) metadata: %{agent_created: true}
})
task2_agent1 = Task.new("Add auth tests", "Write auth tests", %{ task2_agent1 =
priority: :normal, Task.new("Add auth tests", "Write auth tests", %{
assigned_agent: agent1.id, priority: :normal,
metadata: %{agent_created: true} assigned_agent: agent1.id,
}) metadata: %{agent_created: true}
})
# Tasks for Agent 2 # Tasks for Agent 2
task1_agent2 = Task.new("Write API docs", "Document endpoints", %{ task1_agent2 =
priority: :normal, Task.new("Write API docs", "Document endpoints", %{
assigned_agent: agent2.id, priority: :normal,
metadata: %{agent_created: true} assigned_agent: agent2.id,
}) metadata: %{agent_created: true}
})
# Add tasks to respective inboxes # Add tasks to respective inboxes
Inbox.add_task(agent1.id, task1_agent1) Inbox.add_task(agent1.id, task1_agent1)
@@ -76,7 +79,12 @@ IO.puts("\n4⃣ Checking remaining tasks...")
status1 = Inbox.get_status(agent1.id) status1 = Inbox.get_status(agent1.id)
status2 = Inbox.get_status(agent2.id) status2 = Inbox.get_status(agent2.id)
IO.puts("Agent 1: #{status1.pending_count} pending, current: #{if status1.current_task, do: status1.current_task.title, else: "none"}") IO.puts(
IO.puts("Agent 2: #{status2.pending_count} pending, current: #{if status2.current_task, do: status2.current_task.title, else: "none"}") "Agent 1: #{status1.pending_count} pending, current: #{if status1.current_task, do: status1.current_task.title, else: "none"}"
)
IO.puts(
"Agent 2: #{status2.pending_count} pending, current: #{if status2.current_task, do: status2.current_task.title, else: "none"}"
)
IO.puts("\n🎉 SUCCESS! Agent-specific task pools working!") IO.puts("\n🎉 SUCCESS! Agent-specific task pools working!")

View File

@@ -90,14 +90,17 @@ defmodule SessionManagementTest do
case Jason.decode(body) do case Jason.decode(body) do
{:ok, %{"result" => _result}} -> {:ok, %{"result" => _result}} ->
IO.puts(" ✅ Valid MCP response received") IO.puts(" ✅ Valid MCP response received")
{:ok, %{"error" => error}} -> {:ok, %{"error" => error}} ->
IO.puts(" ⚠️ MCP error: #{inspect(error)}") IO.puts(" ⚠️ MCP error: #{inspect(error)}")
_ -> _ ->
IO.puts(" ❌ Invalid response format") IO.puts(" ❌ Invalid response format")
end end
{:ok, %HTTPoison.Response{status_code: status_code, body: body}} -> {:ok, %HTTPoison.Response{status_code: status_code, body: body}} ->
IO.puts("❌ Request failed with status #{status_code}") IO.puts("❌ Request failed with status #{status_code}")
case Jason.decode(body) do case Jason.decode(body) do
{:ok, parsed} -> IO.puts(" Error: #{inspect(parsed)}") {:ok, parsed} -> IO.puts(" Error: #{inspect(parsed)}")
_ -> IO.puts(" Body: #{body}") _ -> IO.puts(" Body: #{body}")

View File

@@ -10,6 +10,7 @@ Process.sleep(1000)
# Test 1: Initialize call (system call, should work without agent_id) # Test 1: Initialize call (system call, should work without agent_id)
IO.puts("Testing initialize call...") IO.puts("Testing initialize call...")
init_request = %{ init_request = %{
"jsonrpc" => "2.0", "jsonrpc" => "2.0",
"id" => 1, "id" => 1,
@@ -31,6 +32,7 @@ IO.puts("Initialize response: #{inspect(init_response)}")
# Test 2: Tools/list call (system call, should work without agent_id) # Test 2: Tools/list call (system call, should work without agent_id)
IO.puts("\nTesting tools/list call...") IO.puts("\nTesting tools/list call...")
tools_request = %{ tools_request = %{
"jsonrpc" => "2.0", "jsonrpc" => "2.0",
"id" => 2, "id" => 2,
@@ -42,6 +44,7 @@ IO.puts("Tools/list response: #{inspect(tools_response)}")
# Test 3: Register agent call (should work) # Test 3: Register agent call (should work)
IO.puts("\nTesting register_agent call...") IO.puts("\nTesting register_agent call...")
register_request = %{ register_request = %{
"jsonrpc" => "2.0", "jsonrpc" => "2.0",
"id" => 3, "id" => 3,
@@ -59,7 +62,8 @@ register_response = GenServer.call(AgentCoordinator.MCPServer, {:mcp_request, re
IO.puts("Register agent response: #{inspect(register_response)}") IO.puts("Register agent response: #{inspect(register_response)}")
# Test 4: Try a call that requires agent_id (should fail without agent_id) # Test 4: Try a call that requires agent_id (should fail without agent_id)
IO.puts("\nTesting call that requires agent_id (should fail)...") IO.puts("Testing call that requires agent_id (should fail)...")
task_request = %{ task_request = %{
"jsonrpc" => "2.0", "jsonrpc" => "2.0",
"id" => 4, "id" => 4,
@@ -76,4 +80,4 @@ task_request = %{
task_response = GenServer.call(AgentCoordinator.MCPServer, {:mcp_request, task_request}) task_response = GenServer.call(AgentCoordinator.MCPServer, {:mcp_request, task_request})
IO.puts("Task creation response: #{inspect(task_response)}") IO.puts("Task creation response: #{inspect(task_response)}")
IO.puts("\nAll tests completed!")" IO.puts("All tests completed!")

View File

@@ -11,14 +11,17 @@ IO.puts("Testing VS Code tool integration...")
# Check if VS Code tools are available # Check if VS Code tools are available
tools = AgentCoordinator.MCPServer.get_tools() tools = AgentCoordinator.MCPServer.get_tools()
vscode_tools = Enum.filter(tools, fn tool ->
case Map.get(tool, "name") do vscode_tools =
"vscode_" <> _ -> true Enum.filter(tools, fn tool ->
_ -> false case Map.get(tool, "name") do
end "vscode_" <> _ -> true
end) _ -> false
end
end)
IO.puts("Found #{length(vscode_tools)} VS Code tools:") IO.puts("Found #{length(vscode_tools)} VS Code tools:")
Enum.each(vscode_tools, fn tool -> Enum.each(vscode_tools, fn tool ->
IO.puts(" - #{tool["name"]}") IO.puts(" - #{tool["name"]}")
end) end)