Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sub-agents-mcp",
"version": "0.2.3",
"version": "0.3.0",
"mcpName": "io.github.shinpr/sub-agents-mcp",
"description": "MCP server for delegating tasks to specialized AI assistants in Cursor and other tools",
"main": "dist/index.js",
Expand Down
9 changes: 5 additions & 4 deletions server.json
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
"name": "io.github.shinpr/sub-agents-mcp",
"description": "MCP server for delegating tasks to specialized AI assistants in Cursor and other tools",
"description": "MCP server for delegating tasks to specialized AI assistants in Cursor, Claude, and Gemini",
"vendor": "Shinsuke Kagawa",
"license": "MIT",
"repository": {
"url": "https://github.com/shinpr/sub-agents-mcp",
"source": "github"
},
"version": "0.2.3",
"version": "0.3.0",
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "sub-agents-mcp",
"version": "0.2.3",
"version": "0.3.0",
"transport": {
"type": "stdio"
},
Expand All @@ -28,7 +28,7 @@
},
{
"name": "AGENT_TYPE",
"description": "Type of AI CLI to use: 'cursor' or 'claude'",
"description": "Type of AI CLI to use: 'cursor', 'claude', or 'gemini'",
"isRequired": true,
"format": "string",
"isSecret": false
Expand Down Expand Up @@ -86,6 +86,7 @@
"ai-agents",
"claude-code",
"cursor",
"gemini",
"llm",
"cli",
"agent-orchestration"
Expand Down
11 changes: 4 additions & 7 deletions src/__tests__/integration/McpServer.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,21 +83,18 @@ describe('McpServer Integration', () => {
expect(agentListResource?.description).toContain('List of available Claude Code sub-agents')
})

it('should provide individual agent resources', async () => {
it('should provide individual agent resources with valid URI format', async () => {
const resources = await server.listResources()

// Check if individual agent resources are available
const agentResources = resources.filter(
(resource) => resource.uri.startsWith('agents://') && resource.uri !== 'agents://list'
)

expect(agentResources.length).toBeGreaterThanOrEqual(0)

// If there are agent resources, verify their structure
if (agentResources.length > 0) {
const agentResource = agentResources[0]
expect(agentResource.name).toBeDefined()
expect(agentResource.description).toBeDefined()
for (const agentResource of agentResources) {
expect(agentResource.name).toBeTruthy()
expect(agentResource.description).toBeTruthy()
expect(agentResource.uri).toMatch(/^agents:\/\/[\w-]+$/)
}
})
Expand Down
9 changes: 9 additions & 0 deletions src/__tests__/integration/ServerConfig.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ describe('ServerConfig', () => {
expect(config.agentType).toBe('claude')
})

it('should load AGENT_TYPE as gemini when set', () => {
vi.stubEnv('AGENTS_DIR', testAgentsDir)
vi.stubEnv('AGENT_TYPE', 'gemini')

const config = new ServerConfig()

expect(config.agentType).toBe('gemini')
})

it('should throw error when AGENTS_DIR is not set', () => {
// Ensure AGENTS_DIR is not set
vi.stubEnv('AGENTS_DIR', undefined)
Expand Down
16 changes: 5 additions & 11 deletions src/__tests__/integration/SessionManager.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ describe('SessionManager', () => {
}
})

it('should log deleted file count', async () => {
it('should delete multiple old files in a single cleanup', async () => {
const manager = new SessionManager(sessionConfig)

// Create two old files (different session IDs)
Expand All @@ -530,19 +530,13 @@ describe('SessionManager', () => {
await fs.utimes(oldFilePath1, eightDaysAgo, eightDaysAgo)
await fs.utimes(oldFilePath2, eightDaysAgo, eightDaysAgo)

// Spy on console.log
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

// Execute cleanup
await manager.cleanupOldSessions()

// Verify log was called with deletion count
expect(consoleLogSpy).toHaveBeenCalled()
const logCalls = consoleLogSpy.mock.calls
const hasCleanupLog = logCalls.some((call) => JSON.stringify(call).includes('Cleaned up'))
expect(hasCleanupLog).toBe(true)

consoleLogSpy.mockRestore()
// Assert - focus on behavior: both old files are deleted
const remainingFiles = await fs.readdir(testSessionDir)
expect(remainingFiles).not.toContain(oldFile1)
expect(remainingFiles).not.toContain(oldFile2)
})
})
})
32 changes: 17 additions & 15 deletions src/agents/__tests__/AgentManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ This agent does amazing things.`
})

describe('Agent Loading', () => {
it('should load agent definitions on every request', async () => {
it('should return consistent agent data across multiple requests', async () => {
// Arrange
const mockFiles = ['cached-agent.md']
const mockContent = '# Cached Agent\nThis agent should be loaded.'
Expand All @@ -229,14 +229,14 @@ This agent does amazing things.`
const firstCall = await agentManager.getAgent('cached-agent')
const secondCall = await agentManager.getAgent('cached-agent')

// Assert
// Assert - focus on behavior: same agent data is returned
expect(firstCall).toBeDefined()
expect(secondCall).toBeDefined()
expect(firstCall).toEqual(secondCall)
expect(mockReaddir).toHaveBeenCalledTimes(2) // Should read directory each time
expect(firstCall!.name).toBe(secondCall!.name)
expect(firstCall!.content).toBe(secondCall!.content)
})

it('should load all agents on every listAgents call', async () => {
it('should return all agents from directory', async () => {
// Arrange
const mockFiles = ['agent1.md', 'agent2.txt']
const mockContent = '# Test Agent\nTest content.'
Expand All @@ -251,18 +251,17 @@ This agent does amazing things.`
})

// Act
const firstList = await agentManager.listAgents()
const secondList = await agentManager.listAgents()
const agents = await agentManager.listAgents()

// Assert
expect(firstList).toHaveLength(2)
expect(secondList).toHaveLength(2)
expect(mockReaddir).toHaveBeenCalledTimes(2) // Should read directory each time
// Assert - focus on behavior: correct number and names of agents
expect(agents).toHaveLength(2)
expect(agents.map((a) => a.name)).toContain('agent1')
expect(agents.map((a) => a.name)).toContain('agent2')
})
})

describe('Agent Refresh', () => {
it('should reload agents when refreshAgents is called', async () => {
it('should detect newly added agents after refresh', async () => {
// Arrange
const initialFiles = ['initial-agent.md']
const refreshedFiles = ['initial-agent.md', 'new-agent.md']
Expand All @@ -285,14 +284,17 @@ This agent does amazing things.`
// Act - Initial load
const initialAgents = await agentManager.listAgents()

// Act - Refresh
// Act - Refresh (simulates new file added to directory)
await agentManager.refreshAgents()
const refreshedAgents = await agentManager.listAgents()

// Assert
// Assert - focus on behavior: new agent is now visible
expect(initialAgents).toHaveLength(1)
expect(initialAgents.map((a) => a.name)).toContain('initial-agent')

expect(refreshedAgents).toHaveLength(2)
expect(mockReaddir).toHaveBeenCalledTimes(3) // One for each operation
expect(refreshedAgents.map((a) => a.name)).toContain('initial-agent')
expect(refreshedAgents.map((a) => a.name)).toContain('new-agent')
})
})

Expand Down
4 changes: 2 additions & 2 deletions src/config/ServerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class ServerConfig {
public readonly agentsDir: string

/** Type of agent to use for execution */
public readonly agentType: 'cursor' | 'claude'
public readonly agentType: 'cursor' | 'claude' | 'gemini'

/** Log level for server operations */
public readonly logLevel: 'debug' | 'info' | 'warn' | 'error'
Expand Down Expand Up @@ -69,7 +69,7 @@ export class ServerConfig {
}
this.agentsDir = agentsDir

this.agentType = (process.env['AGENT_TYPE'] as 'cursor' | 'claude') || 'cursor'
this.agentType = (process.env['AGENT_TYPE'] as 'cursor' | 'claude' | 'gemini') || 'cursor'
this.logLevel = (process.env['LOG_LEVEL'] as 'debug' | 'info' | 'warn' | 'error') || 'info'

const timeoutEnv = process.env['EXECUTION_TIMEOUT_MS']
Expand Down
18 changes: 13 additions & 5 deletions src/execution/AgentExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ export interface ExecutionConfig {

/**
* Type of agent to use for execution.
* 'cursor' or 'claude'
* 'cursor', 'claude', or 'gemini'
*/
agentType: 'cursor' | 'claude'
agentType: 'cursor' | 'claude' | 'gemini'
}

export const DEFAULT_EXECUTION_TIMEOUT = 300000 // 5 minutes
Expand All @@ -70,7 +70,7 @@ export const DEFAULT_EXECUTION_TIMEOUT = 300000 // 5 minutes
* @param overrides - Optional overrides for thresholds
*/
export function createExecutionConfig(
agentType: 'cursor' | 'claude',
agentType: 'cursor' | 'claude' | 'gemini',
overrides?: Partial<Omit<ExecutionConfig, 'agentType'>>
): ExecutionConfig {
return {
Expand Down Expand Up @@ -218,10 +218,18 @@ export class AgentExecutor {
return new Promise((resolve) => {
// Generate command and args - both CLIs use the same interface
const formattedPrompt = `[System Context]\n${params.agent}\n\n[User Prompt]\n${params.prompt}`
const args = ['--output-format', 'json', '-p', formattedPrompt]
// Use stream-json for Gemini (each line is a complete JSON object)
// Use json for Cursor and Claude (single JSON response)
const outputFormat = this.config.agentType === 'gemini' ? 'stream-json' : 'json'
const args = ['--output-format', outputFormat, '-p', formattedPrompt]

// Determine command based on agent type
const command = this.config.agentType === 'claude' ? 'claude' : 'cursor-agent'
const command =
this.config.agentType === 'claude'
? 'claude'
: this.config.agentType === 'gemini'
? 'gemini'
: 'cursor-agent'

// Add API key for cursor-cli if available
if (this.config.agentType === 'cursor' && process.env['CLI_API_KEY']) {
Expand Down
64 changes: 56 additions & 8 deletions src/execution/StreamProcessor.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
/**
* StreamProcessor - Simplified stream processing for agent output
*
* Handles both cursor and claude output in JSON format.
* Both agents now use --output-format json and return a single JSON response.
* Handles cursor, claude, and gemini output in JSON format.
* - Cursor/Claude: Use --output-format json, return a single JSON with type: "result"
* - Gemini: Uses --output-format stream-json, returns multiple JSON lines,
* assistant messages contain the response, type: "result" signals completion
*/
export class StreamProcessor {
private resultJson: unknown = null
private geminiResponseParts: string[] = []
private isGeminiStreamJson = false

/**
* Process a single line from the agent output stream.
* Returns true when a valid JSON is detected, false otherwise.
* Returns true when a valid result JSON is detected, false otherwise.
*
* For Cursor/Claude: The first JSON line with type: "result" is the result.
* For Gemini stream-json: Accumulate assistant messages, return when type: "result" is seen.
*
* @param line - Raw line from stdout
* @returns true if processing is complete, false to continue
Expand All @@ -22,16 +29,57 @@ export class StreamProcessor {
return false
}

// If we already have a result, ignore subsequent lines
if (this.resultJson !== null) {
return false
}

// Try to parse as JSON
try {
const json = JSON.parse(trimmedLine)
const json = JSON.parse(trimmedLine) as Record<string, unknown>

// Store the first valid JSON response
if (!this.resultJson) {
this.resultJson = json
// Detect Gemini stream-json format by init message
if (json['type'] === 'init') {
this.isGeminiStreamJson = true
return false
}

// For Gemini: accumulate assistant message content
if (
this.isGeminiStreamJson &&
json['type'] === 'message' &&
json['role'] === 'assistant' &&
typeof json['content'] === 'string'
) {
this.geminiResponseParts.push(json['content'])
return false
}

// Check if this is a result JSON
if (json['type'] === 'result') {
// For Gemini: construct result with accumulated response
if (this.isGeminiStreamJson) {
this.resultJson = {
type: 'result',
result: this.geminiResponseParts.join(''),
stats: json['stats'],
status: json['status'],
}
} else {
// Cursor/Claude: use as-is
this.resultJson = json
}
return true // Processing complete
}
return false // Ignore subsequent JSONs

// For backwards compatibility: store first valid JSON if no type field
// This handles any CLI that doesn't use the type field
if (!('type' in json)) {
this.resultJson = json
return true
}

return false // Continue processing (not a result type)
} catch {
// Not valid JSON, ignore
return false
Expand Down
Loading
Loading