# line-mcp-service > LINE MCP service architecture — poll-centric messaging, multi-agent status, SQLite storage, token pooling, agent lifecycle. - Author: Pone Z Pyo - Repository: zoulabo/line-hive - Version: 20260209132217 - Stars: 0 - Forks: 0 - Last Updated: 2026-02-09 - Source: https://github.com/zoulabo/line-hive - Web: https://mule.run/skillshub/@@zoulabo/line-hive~line-mcp-service:20260209132217 --- --- name: line-mcp-service description: 'LINE MCP service architecture — poll-centric messaging, multi-agent status, SQLite storage, token pooling, agent lifecycle.' --- # LINE MCP Service ## When to Use - Understanding the overall architecture and component relationships - Setting up a new development environment - Making changes to core infrastructure (stores, config, server) - Debugging startup, lifecycle, or multi-agent issues For specific subsystems, see: - [message-flows](../message-flows/SKILL.md) — webhook routing, targeting, session fulfillment - [reply-tokens](../reply-tokens/SKILL.md) — token pooling, limits, testing ## Architecture **Poll-centric design**: User queries status via LINE; agent updates status asynchronously. 1. **User polls via LINE**: sends "?" → webhook replies with agent state (free via replyToken) 2. **Agent sets status**: `line_set_status` declares what it's working on or what input it needs 3. **Agent asks (sparingly)**: `line_ask` for direct conversations; `line_send_message` for urgent pushes ```mermaid %%{init: {"theme": "default"}}%% flowchart LR subgraph VSCode[" "] VSTitle["Editor"] Agent["AI Agent"] end subgraph Server[" "] ServerTitle["MCP Server"] Status["Agent Status"] Store["Message Store"] Webhook["Webhook Handler"] end subgraph External[" "] ExtTitle["LINE Platform"] LineAPI["Messaging API"] end Agent -->|"set_status"| Status Agent -.->|"ask / send_message"| LineAPI LineAPI -->|"webhook POST"| Webhook Webhook -->|"query status"| Status Webhook -->|"reply (free)"| LineAPI Webhook -->|"store input"| Store Store -->|"pending reply"| Agent style VSCode fill:#e3f2fd,stroke:#1565c0 style Server fill:#f3e5f5,stroke:#7b1fa2 style External fill:#e8f5e9,stroke:#2e7d32 classDef title fill:#4a90d9,stroke:#2c5282,color:#fff,font-weight:bold class VSTitle,ServerTitle,ExtTitle title ``` **Why poll-centric?** - **Free**: replyToken replies don't consume monthly push quota - **Instant**: Webhook responds immediately — no need for agent to be available - **Handles "agent busy"**: Status is in DB; user gets response even if agent is mid-computation - **User-initiated**: User checks when convenient, not interrupted by push noise ## MCP Tools | Tool | Purpose | Blocking | Usage | |------|---------|----------|-------| | `line_set_status` | Set agent description + name (status auto-derived) | No | **During LINE sessions only** | | `line_ask` | Send message + wait for reply in one call | Yes | **Preferred** for conversations | | `line_send_message` | Push notification (waits by default) | Yes (default) | **Sparingly** — urgent only | | `line_check_messages` | Fetch unclaimed messages | No | Fallback | | `line_list_agents` | List all active agents | No | Cross-window visibility | **Piggybacked replies:** All tools (except `line_ask`) include `pendingReply` in their response if the user has sent a reply. Agents receive replies as a side-effect of any tool call. Full input/output schemas: [references/tool-schemas.ts](./references/tool-schemas.ts) ### Recommended Pattern ``` 1. line_set_status({ description: 'Building auth module', agentName: 'Nova' }) → auto-status: working 2. line_ask({ text: 'Should I deploy?', agentName: 'Nova' }) → auto-status: needs_input → user replies → auto-status: working 3. line_set_status({ description: 'Deploying...' }) → auto-status: working 4. line_set_status({}) → auto-status: idle (or just let timeout handle it) ``` **Auto-status values**: `idle` | `working` | `needs_input` | `error` ## Components ### Message Store (SQLite) Local persistence via `better-sqlite3` (WAL mode for concurrent access). All agent instances share `~/.line-mcp/line-mcp.db`. **Key tables:** `messages` (incoming), `sessions` (agent wait sessions), `outgoing` (audit trail), `config` (auto-registration, targeting), `agent_status` (poll-centric status), `reply_tokens` (token pool). See [store schema snippet](./references/schema.sql) for full DDL. **TTL & Cleanup** (runs every 60s): - Sessions: expire after configurable timeout (default 2h), checked in wait loops - Messages: consumed >24h and unclaimed >24h auto-deleted - Reply tokens: consumed or older than 15 min auto-deleted - Dead agents: purged from status table after 30 min with no heartbeat ### Auto-Registration - `LINE_DEFAULT_USER_ID` env var → used if set - Otherwise: first webhook message auto-saves `source.userId` to `config` table - Tools return `{error: 'no_user_registered'}` if called before registration ### Webhook Handler Parses LINE messages as commands and responds via replyToken (free). See [message-flows](../message-flows/SKILL.md) for full routing details. | Input | Action | |-------|--------| | `?` / `status` / `s` | Numbered agent list | | A number (e.g. `2`) | Always triggers agent selection (never forwarded to agents) | | Other text | Route to targeted/waiting agent | ### Multi-Agent Architecture - **Shared SQLite DB**: All agents share one DB via WAL mode - **Agent ID**: SHA-256 hash of workspace path (first 12 chars) — this is the base ID - **Per-agentName rows**: Each unique `agentName` creates a separate status row with composite key `{baseId}:{agentName}`. Rows are created lazily on first `setStatus` call (no base row at init) - **Name uniqueness enforced**: `isAgentNameTaken()` checks if any live agent (different base ID) already uses the name. `line_set_status`, `line_ask`, and `line_send_message` all return `agent_name_taken` error on collision - **Persona naming**: Agents pick cool, gender-neutral persona names (e.g., "Nova", "Echo", "Sage") — not task-based names - **Heartbeat**: Process heartbeat every 10s updates only the base row. Sub-agents refresh their own heartbeat via tool calls (setStatus UPSERT). Dead sub-agents (2 min no activity) auto-expire independently of the parent process - **Webhook port**: First agent claims port 19780; others piggyback via shared DB. If the port owner exits, another agent auto-claims it within 10s (port migration) - **Aggregated status**: Single agent = backward-compatible; multiple = numbered list with workspace names - **Same MCP process**: VS Code spawns one MCP process per server entry per workspace. Multiple chat sessions share the same process and StatusStore instance ## Project Structure ``` line-mcp-service/ ├── bin/line-mcp.js # CLI entry point ├── src/ │ ├── index.ts # MCP server entry + webhook + tunnel │ ├── server.ts # MCP tool definitions + handlers │ ├── config.ts # Env validation (graceful — empty credentials allowed) │ ├── logger.ts # Pino (MUST use stderr — stdout is MCP JSON-RPC) │ ├── tunnel.ts # ngrok tunnel (auto-restart with backoff) │ ├── cli/ │ │ ├── init.ts # Interactive setup wizard │ │ ├── doctor.ts # Diagnose setup issues (cross-platform) │ │ ├── test-connection.ts # LINE API connection tester │ │ ├── config-writer.ts # Write .env, mcp.json, instructions │ │ └── webhook-capture.ts # Temp server to capture userId │ ├── tools/ # One file per MCP tool │ ├── line/ │ │ ├── client.ts # LINE API client (lazy init + stubs) │ │ ├── messages.ts # Message builders, quick reply, postback │ │ └── webhook.ts # Webhook handler + status responder │ ├── store/ │ │ ├── messageStore.ts # Message/session persistence │ │ └── statusStore.ts # Agent status management │ ├── util/ │ │ ├── persistUserId.ts # Auto-register userId from webhook │ │ └── sendWithTokenPool.ts # Reply token pool → push fallback │ └── types/index.ts ├── tests/ # Vitest — all use in-memory SQLite ├── templates/ │ └── line-notification.instructions.md └── .env.example ``` ## Environment Variables ```bash # Required LINE_CHANNEL_ACCESS_TOKEN= # From LINE Developers Console LINE_CHANNEL_SECRET= # For webhook signature verification # Optional LINE_DEFAULT_USER_ID= # Auto-registered on first message if omitted WEBHOOK_PORT=19780 WEBHOOK_PATH=/webhook SQLITE_PATH=~/.line-mcp/line-mcp.db SESSION_TTL_MS=7200000 # Default wait timeout (2 hours) CLEANUP_INTERVAL_MS=60000 # Cleanup job interval LOG_LEVEL=info # debug | info | warn | error WORKSPACE_PATH= # Override for agent ID generation AGENT_NAME= # Override human-readable agent name HEARTBEAT_INTERVAL_MS=10000 HEARTBEAT_TIMEOUT_MS=600000 ``` ## Testing **Runner:** Vitest — native TypeScript, no compile step. All core logic uses in-memory SQLite (`:memory:`). No LINE API mocking needed for store/routing tests. | Test file | Covers | |-----------|--------| | `store.test.ts` | Message CRUD, session lifecycle, cleanup, auto-registration | | `status.test.ts` | Status transitions, pending_reply, multi-agent tracking, heartbeat | | `webhook.test.ts` | HMAC-SHA256 verification, dedupe, message filtering | | `routing.test.ts` | FIFO routing, targeted routing, self-adopt race conditions | | `tools.test.ts` | Tool handlers (ask, sendMessage, setStatus, etc.) | | `tunnel.test.ts` | Tunnel management, auto-restart | | `persist-userid.test.ts` | User ID persistence | ## Implementation Gotchas ### ESM + MCP SDK `@modelcontextprotocol/sdk` is ESM-only. **All relative imports must use `.js` extensions:** ```typescript import { MessageStore } from './store/messageStore.js'; // ✅ import { AgentStatus } from './types/index.js'; // ✅ (not ./types) ``` ### LINE SDK Lazy Client `@line/bot-sdk` Client constructor throws on empty token. Solution: lazy init with stubs when token is missing — server starts without credentials for tool discovery. ### Pino Must Use stderr Stdout is MCP JSON-RPC transport. `pino({ level: 'info' }, pino.destination(2))`. ### Config: Graceful Empty Credentials Config must not crash on empty credentials. VS Code needs to discover tools even without LINE configured. Fail at tool-call time instead. ### MCP Server Lifecycle **Never blindly kill port 19780.** When running via MCP stdio, VS Code manages the process. The "Restart MCP Server" task kills the process; VS Code auto-reconnects on the next tool call. There is no agent-accessible VS Code command to restart MCP servers — the kill + next-tool-call approach is the available method. ### Port Migration When the webhook port owner exits, remaining agents auto-detect the free port (checked every 10s with heartbeat) and claim it, restarting the webhook server and ngrok tunnel. This ensures continuous webhook availability when workspaces close. ### Auto-Status Architecture `line_set_status` no longer accepts a `status` parameter — status is auto-derived: - **description present → working**, absent/null → idle - `line_ask`: sets `needs_input` on wait start, `working` on reply, `idle` on timeout, `error` on cancel/failure - `line_send_message`: sets `working` before send, `needs_input` while waiting, `working`/`idle`/`error` on result - `line_check_messages`: sets `working` Valid statuses: `idle` | `working` | `needs_input` | `error` (no `completed`). ### Status Display Format Active agents: `🔧 Nyx: Working on: task (2m ago)` — time reflects when status was set. Offline agents: `🔌 Nyx: offline (working: task, 15m ago)`. ### Description Restoration After Ask When `line_ask` or `line_send_message` transitions to `needs_input`, the previous description is captured. After the user replies and status is restored to `working`, the previous description is restored rather than being cleared. This prevents the status from losing context during ask/reply cycles. ### WORKSPACE_PATH Required in mcp.json Without it, `process.cwd()` defaults to home directory and `deriveAgentName()` returns the OS username instead of the project name. ### Sub-Agent Heartbeat Lifecycle Sub-agents (identified by composite key `baseId:agentName`) have independent heartbeats: 1. **Created**: First `setStatus` with `agentName` creates the row with fresh heartbeat 2. **Active**: Each tool call refreshes heartbeat_at via setStatus UPSERT 3. **Poll-alive**: During `line_ask`/`waitForReply` poll loops, `refreshHeartbeat()` fires every 30s 4. **Inactive**: Agent stops calling tools — heartbeat_at freezes 5. **Expired**: After 10 min with no tool calls or poll heartbeats, `removeDeadAgents()` deletes the row 6. **Base row**: Always alive via process heartbeat (every 10s) ```mermaid %%{init: {"theme": "default"}}%% stateDiagram-v2 [*] --> Created: setStatus with agentName Created --> Active: tool calls refresh heartbeat Active --> Active: setStatus UPSERT / refreshHeartbeat Active --> Inactive: no tool calls Inactive --> Expired: 10 min timeout Expired --> [*]: removeDeadAgents deletes row Inactive --> Active: agent resumes tool calls ``` This prevents ghost sub-agents from staying alive forever when the MCP process outlives the chat session. ### Webhook Acks for Targeted Routing When a chat message arrives and a targeted agent exists: - **Agent is waiting** (active session): Token saved silently — agent responds within seconds via `line_ask` polling - **Agent is working** (no active session): Sends ack `📨 Queued for AgentName (N queued)` with quick reply buttons When no targeted agent exists: - **Agents alive** (untargeted): Sends ack `💬 Message received` with agent-selection quick reply buttons - **No agents alive**: Sends ack `💬 Message received` with zero-agent quick reply (Continue / Status / Queued) ### Dead Agent Message Cleanup When the webhook detects a dead targeted agent (heartbeat expired), it: 1. Clears `targeted_agent_id` 2. Calls `messageStore.clearTargetForDeadAgent(agentId)` to un-target orphaned messages (sets `target_agent_id = NULL`) 3. Messages become available to any agent via FIFO routing **Important:** This cleanup runs at the top of `handleCommandReply` for ALL command types (status, show_queued, select, chat), not just chat. This ensures status checks and queue views also see up-to-date targeting. ### Unsend Handling When a user unsends a message, the webhook deletes the message row from the DB entirely (`deleteByLineMessageId`). Only unclaimed messages are deleted — already-consumed messages are unaffected. ### Session-Scoped Message Adoption `StatusStore.sessionStartTime` prevents stale messages from previous sessions being adopted by new ones. `countUnclaimedMessages(sessionStartTime)` filters `queuedRemaining` to adoptable messages only. ### Agent-Scoped queuedMessages `set_status` only reports `queuedMessages` when the calling agent is the currently targeted agent (via `targeted_agent_id` config) or when no targeting is set. This prevents agent B from seeing queued messages intended for agent A. ## References - [Setup Guide](./references/setup-guide.md) — LINE bot prerequisites, wizard, VS Code config - [Tool Schemas](./references/tool-schemas.ts) — MCP tool input/output definitions - [Message Flows](../message-flows/SKILL.md) — Routing, targeting, token pooling - [Reply Tokens](../reply-tokens/SKILL.md) — Token limits, testing, pooling strategy - [MCP Specification](https://spec.modelcontextprotocol.io/) - [LINE Messaging API](https://developers.line.biz/en/docs/messaging-api/)