#!/usr/bin/env node
/**
* Councly MCP Server
*
* Model Context Protocol server for Councly council hearings.
* Communicates via stdio with MCP clients (Claude Code, Codex, etc.).
*
* Usage:
* COUNCLY_API_KEY=cnc_xxx npx @councly/mcp
*
* Or in Claude Code settings:
* {
* "mcpServers": {
* "councly": {
* "command": "npx",
* "args": ["@councly/mcp"],
* "env": {
* "COUNCLY_API_KEY": "cnc_xxx"
* }
* }
* }
* }
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ErrorCode,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { CounclyClient, CounclyApiError } from './api-client.js';
import { TOOL_DEFINITIONS, counclyHearingSchema, counclyStatusSchema } from './tools.js';
// Server metadata
const SERVER_NAME = 'councly-mcp';
const SERVER_VERSION = '0.1.0';
// Get API key from environment
function getApiKey(): string {
const apiKey = process.env.COUNCLY_API_KEY;
if (!apiKey) {
console.error('Error: COUNCLY_API_KEY environment variable is required');
console.error('Get your API key at https://councly.ai/settings/mcp');
process.exit(1);
}
return apiKey;
}
// Format hearing result for display
function formatHearingResult(status: {
hearingId: string;
status: string;
verdict?: string | null;
trustScore?: number | null;
totalCost?: number;
counselSummaries?: Array<{
seat: string;
model: string;
lastTurn: string;
}>;
progress?: number;
error?: string;
}): string {
const lines: string[] = [];
lines.push(`## Hearing ${status.hearingId}`);
lines.push(`**Status:** ${status.status}`);
if (status.status === 'completed' || status.status === 'early_stopped') {
if (status.verdict) {
lines.push('');
lines.push('### Verdict');
lines.push(status.verdict);
}
if (status.trustScore !== null && status.trustScore !== undefined) {
lines.push('');
lines.push(`**Trust Score:** ${status.trustScore}/100`);
}
if (status.counselSummaries && status.counselSummaries.length > 0) {
lines.push('');
lines.push('### Counsel Perspectives');
for (const counsel of status.counselSummaries) {
lines.push(`**${counsel.seat}** (${counsel.model}):`);
lines.push(counsel.lastTurn);
lines.push('');
}
}
if (status.totalCost !== undefined) {
lines.push(`**Cost:** ${status.totalCost} credits`);
}
} else if (status.status === 'failed') {
lines.push(`**Error:** ${status.error || 'Unknown error'}`);
} else {
// In progress
if (status.progress !== undefined) {
lines.push(`**Progress:** ${status.progress}%`);
}
}
return lines.join('\n');
}
async function main() {
const apiKey = getApiKey();
const client = new CounclyClient({ apiKey });
// Create MCP server
const server = new Server(
{
name: SERVER_NAME,
version: SERVER_VERSION,
},
{
capabilities: {
tools: {},
},
}
);
// Handle list tools request
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: TOOL_DEFINITIONS,
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'councly_hearing': {
const parsed = counclyHearingSchema.parse(args);
// Create the hearing
const hearing = await client.createHearing({
subject: parsed.subject,
preset: parsed.preset,
workflow: parsed.workflow,
});
// If not waiting, return immediately
if (!parsed.wait) {
return {
content: [
{
type: 'text',
text: `Hearing created: ${hearing.hearingId}\nStatus: ${hearing.status}\nPreset: ${hearing.preset}\nCost: ${hearing.cost.credits} credits\n\nUse councly_status to check progress.`,
},
],
};
}
// Wait for completion
const status = await client.waitForCompletion(hearing.hearingId, {
timeoutMs: parsed.timeout_seconds * 1000,
onProgress: (s) => {
// Progress updates could be logged if needed
if (s.progress !== undefined) {
process.stderr.write(`\rProgress: ${s.progress}%`);
}
},
});
return {
content: [
{
type: 'text',
text: formatHearingResult(status),
},
],
};
}
case 'councly_status': {
const parsed = counclyStatusSchema.parse(args);
const status = await client.getHearingStatus(parsed.hearing_id);
return {
content: [
{
type: 'text',
text: formatHearingResult(status),
},
],
};
}
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
} catch (error) {
if (error instanceof CounclyApiError) {
return {
content: [
{
type: 'text',
text: `API Error (${error.code}): ${error.message}`,
},
],
isError: true,
};
}
if (error instanceof McpError) {
throw error;
}
// Zod validation errors
if (error instanceof Error && error.name === 'ZodError') {
return {
content: [
{
type: 'text',
text: `Validation Error: ${error.message}`,
},
],
isError: true,
};
}
// Unknown error
const message = error instanceof Error ? error.message : 'Unknown error';
return {
content: [
{
type: 'text',
text: `Error: ${message}`,
},
],
isError: true,
};
}
});
// Connect via stdio
const transport = new StdioServerTransport();
await server.connect(transport);
// Log startup to stderr (stdout is for MCP protocol)
console.error(`${SERVER_NAME} v${SERVER_VERSION} started`);
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});