eassist-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # eassist-mcp
2
+
3
+ MCP server for [EAssist](https://eassist.forsysinc.com) — query and manage actions & initiatives via Claude.
4
+
5
+ ## Setup
6
+
7
+ Add to your Claude config (`claude_desktop_config.json` or MCP settings):
8
+
9
+ ```json
10
+ {
11
+ "mcpServers": {
12
+ "eassist": {
13
+ "command": "npx",
14
+ "args": ["-y", "eassist-mcp"],
15
+ "env": {
16
+ "EASSIST_API_URL": "https://eassist.forsysinc.com"
17
+ }
18
+ }
19
+ }
20
+ }
21
+ ```
22
+
23
+ **That's it.** On first use, a browser window opens for Google login. Tokens are stored at `~/.eassist-mcp/auth.json` and refreshed automatically — you'll only ever log in once.
24
+
25
+ ## What you can do
26
+
27
+ Just talk to Claude naturally:
28
+
29
+ - *"What are the overdue actions?"*
30
+ - *"What is Srini working on?"*
31
+ - *"Create an action: Review Q2 budget, assign to Arpit, due June 15"*
32
+ - *"Mark action abc123 as complete"*
33
+ - *"Give me the executive summary"*
34
+ - *"Show me all actions in the JananiMitra initiative"*
35
+ - *"List all members"*
36
+
37
+ ## Available Tools (20)
38
+
39
+ ### Read / Reporting
40
+ | Tool | Description |
41
+ |------|-------------|
42
+ | `get_overdue_actions` | All overdue actions across the org |
43
+ | `get_user_actions` | Actions assigned to a specific person |
44
+ | `get_executive_summary` | Full ELT dashboard — workload, risks, stale actions |
45
+ | `get_executive_brief` | Short executive brief |
46
+ | `search_actions` | Keyword search across all actions |
47
+ | `get_action_detail` | Full detail + comments for one action |
48
+ | `list_initiatives` | All initiatives with status and progress |
49
+ | `get_initiative_report` | Initiative detail + all its actions |
50
+ | `list_members` | All members across all initiatives |
51
+
52
+ ### Create / Update / Delete
53
+ | Tool | Description |
54
+ |------|-------------|
55
+ | `create_action` | Create a new action (optional: link to initiative) |
56
+ | `update_action` | Update title, status, priority, due date, assignees |
57
+ | `complete_action` | Mark an action as completed |
58
+ | `assign_action` | Assign/reassign an action |
59
+ | `delete_action` | Delete an action permanently |
60
+ | `bulk_update_actions` | Update multiple actions at once |
61
+ | `add_comment` | Add a comment/update to an action |
62
+ | `create_initiative` | Create a new initiative |
63
+
64
+ ### Auth / Utility
65
+ | Tool | Description |
66
+ |------|-------------|
67
+ | `whoami` | Show logged-in user and API URL |
68
+ | `logout` | Clear stored tokens (triggers re-login on next call) |
69
+ | `refresh_cache` | Force fresh fetch of members and initiatives |
70
+
71
+ ## Auth details
72
+
73
+ - Tokens stored at: `~/.eassist-mcp/auth.json`
74
+ - Access token: 7-day JWT, auto-refreshed silently
75
+ - Refresh token: 30-day, rotated on each refresh
76
+ - If both expire: browser re-opens automatically
77
+
78
+ ## Requirements
79
+
80
+ - Node.js 18+
81
+ - Access to [eassist.forsysinc.com](https://eassist.forsysinc.com)
@@ -0,0 +1,14 @@
1
+ declare class AuthManager {
2
+ private data;
3
+ private load;
4
+ private store;
5
+ private isExpired;
6
+ private getApiUrl;
7
+ getValidToken(): Promise<string>;
8
+ triggerOAuthFlow(): Promise<void>;
9
+ private findFreePort;
10
+ getStoredName(): string;
11
+ clearTokens(): void;
12
+ }
13
+ export declare const auth: AuthManager;
14
+ export {};
package/build/auth.js ADDED
@@ -0,0 +1,138 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import http from 'http';
5
+ import { URL } from 'url';
6
+ import axios from 'axios';
7
+ const CONFIG_DIR = path.join(os.homedir(), '.eassist-mcp');
8
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'auth.json');
9
+ class AuthManager {
10
+ data = null;
11
+ load() {
12
+ try {
13
+ if (!fs.existsSync(CONFIG_FILE))
14
+ return null;
15
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ store(data) {
22
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
23
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf-8');
24
+ this.data = data;
25
+ }
26
+ isExpired(expiresAt) {
27
+ // Add 60s buffer so we refresh before actual expiry
28
+ return new Date(expiresAt).getTime() - 60_000 < Date.now();
29
+ }
30
+ getApiUrl() {
31
+ const url = process.env.EASSIST_API_URL ?? this.data?.apiUrl;
32
+ if (!url)
33
+ throw new Error('EASSIST_API_URL environment variable is required');
34
+ return url.replace(/\/$/, '');
35
+ }
36
+ async getValidToken() {
37
+ if (!this.data)
38
+ this.data = this.load();
39
+ // Valid access token
40
+ if (this.data?.accessToken && !this.isExpired(this.data.expiresAt)) {
41
+ return this.data.accessToken;
42
+ }
43
+ // Try refresh
44
+ if (this.data?.refreshToken) {
45
+ try {
46
+ const apiUrl = this.getApiUrl();
47
+ const res = await axios.post(`${apiUrl}/auth/refresh`, { refreshToken: this.data.refreshToken });
48
+ const { token, refreshToken, expiresAt } = res.data;
49
+ this.store({ ...this.data, accessToken: token, refreshToken, expiresAt });
50
+ return token;
51
+ }
52
+ catch {
53
+ // Refresh failed — fall through to OAuth
54
+ }
55
+ }
56
+ // Full OAuth flow
57
+ await this.triggerOAuthFlow();
58
+ return this.data.accessToken;
59
+ }
60
+ async triggerOAuthFlow() {
61
+ const apiUrl = this.getApiUrl();
62
+ const port = await this.findFreePort();
63
+ return new Promise((resolve, reject) => {
64
+ const server = http.createServer((req, res) => {
65
+ try {
66
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
67
+ if (url.pathname !== '/callback') {
68
+ res.end();
69
+ return;
70
+ }
71
+ const token = url.searchParams.get('token');
72
+ const refreshToken = url.searchParams.get('refreshToken');
73
+ const expiresAt = url.searchParams.get('expiresAt');
74
+ const name = url.searchParams.get('name') ?? undefined;
75
+ const email = url.searchParams.get('email') ?? undefined;
76
+ if (!token || !refreshToken || !expiresAt) {
77
+ res.end('Missing auth params');
78
+ reject(new Error('OAuth callback missing params'));
79
+ server.close();
80
+ return;
81
+ }
82
+ this.store({ apiUrl, accessToken: token, refreshToken, expiresAt, name, email });
83
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
84
+ res.end('OK');
85
+ server.close();
86
+ resolve();
87
+ }
88
+ catch (err) {
89
+ server.close();
90
+ reject(err);
91
+ }
92
+ });
93
+ server.listen(port, '127.0.0.1', async () => {
94
+ const authUrl = `${apiUrl}/auth/mcp?port=${port}`;
95
+ console.error(`\n[eassist-mcp] Opening browser for authentication...\n${authUrl}\n`);
96
+ try {
97
+ const { default: open } = await import('open');
98
+ await open(authUrl);
99
+ }
100
+ catch {
101
+ console.error('[eassist-mcp] Could not open browser automatically. Please open the URL above manually.');
102
+ }
103
+ });
104
+ server.on('error', reject);
105
+ // Timeout after 5 minutes
106
+ setTimeout(() => {
107
+ server.close();
108
+ reject(new Error('Authentication timed out. Please try again.'));
109
+ }, 5 * 60 * 1000);
110
+ });
111
+ }
112
+ findFreePort() {
113
+ return new Promise((resolve, reject) => {
114
+ const server = http.createServer();
115
+ server.listen(0, '127.0.0.1', () => {
116
+ const addr = server.address();
117
+ server.close(() => {
118
+ if (addr && typeof addr === 'object')
119
+ resolve(addr.port);
120
+ else
121
+ reject(new Error('Could not find free port'));
122
+ });
123
+ });
124
+ });
125
+ }
126
+ getStoredName() {
127
+ return this.data?.name ?? this.data?.email ?? 'Unknown';
128
+ }
129
+ clearTokens() {
130
+ try {
131
+ if (fs.existsSync(CONFIG_FILE))
132
+ fs.unlinkSync(CONFIG_FILE);
133
+ }
134
+ catch { /* ignore */ }
135
+ this.data = null;
136
+ }
137
+ }
138
+ export const auth = new AuthManager();
@@ -0,0 +1,4 @@
1
+ export declare function apiGet<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T>;
2
+ export declare function apiPost<T>(path: string, body?: unknown): Promise<T>;
3
+ export declare function apiPatch<T>(path: string, body?: unknown): Promise<T>;
4
+ export declare function apiDelete<T>(path: string, data?: unknown): Promise<T>;
@@ -0,0 +1,28 @@
1
+ import axios from 'axios';
2
+ import { auth } from './auth.js';
3
+ function getApiUrl() {
4
+ const url = process.env.EASSIST_API_URL;
5
+ if (!url)
6
+ throw new Error('EASSIST_API_URL environment variable is required');
7
+ return url.replace(/\/$/, '');
8
+ }
9
+ async function headers() {
10
+ const token = await auth.getValidToken();
11
+ return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
12
+ }
13
+ export async function apiGet(path, params) {
14
+ const res = await axios.get(`${getApiUrl()}${path}`, { headers: await headers(), params });
15
+ return res.data;
16
+ }
17
+ export async function apiPost(path, body) {
18
+ const res = await axios.post(`${getApiUrl()}${path}`, body, { headers: await headers() });
19
+ return res.data;
20
+ }
21
+ export async function apiPatch(path, body) {
22
+ const res = await axios.patch(`${getApiUrl()}${path}`, body, { headers: await headers() });
23
+ return res.data;
24
+ }
25
+ export async function apiDelete(path, data) {
26
+ const res = await axios.delete(`${getApiUrl()}${path}`, { headers: await headers(), data });
27
+ return res.data;
28
+ }
@@ -0,0 +1,7 @@
1
+ import type { Action, Initiative } from './types.js';
2
+ export declare function fmtDate(iso: string | null | undefined): string;
3
+ export declare function daysOverdue(dueDate: string | null): number;
4
+ export declare function daysUntil(dueDate: string | null): string;
5
+ export declare function fmtAction(a: Action, idx?: number): string;
6
+ export declare function fmtActionTable(actions: Action[], extraCol?: (a: Action) => string, extraHeader?: string): string;
7
+ export declare function fmtInitiativeTable(initiatives: Initiative[]): string;
@@ -0,0 +1,51 @@
1
+ export function fmtDate(iso) {
2
+ if (!iso)
3
+ return '—';
4
+ return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' });
5
+ }
6
+ export function daysOverdue(dueDate) {
7
+ if (!dueDate)
8
+ return 0;
9
+ return Math.max(0, Math.floor((Date.now() - new Date(dueDate).getTime()) / 86_400_000));
10
+ }
11
+ export function daysUntil(dueDate) {
12
+ if (!dueDate)
13
+ return '—';
14
+ const d = Math.floor((new Date(dueDate).getTime() - Date.now()) / 86_400_000);
15
+ if (d < 0)
16
+ return `${Math.abs(d)}d overdue`;
17
+ if (d === 0)
18
+ return 'today';
19
+ return `in ${d}d`;
20
+ }
21
+ export function fmtAction(a, idx) {
22
+ const num = idx != null ? `${idx + 1}. ` : '';
23
+ const assignees = a.assignees.map(u => u.name).join(', ') || 'Unassigned';
24
+ const due = a.dueDate ? ` | Due: ${fmtDate(a.dueDate)} (${daysUntil(a.dueDate)})` : '';
25
+ const init = a.initiative ? ` | ${a.initiative.title}` : '';
26
+ return `${num}**${a.title}**\n Status: ${a.status} | Priority: ${a.priority} | Assignee: ${assignees}${due}${init}`;
27
+ }
28
+ export function fmtActionTable(actions, extraCol, extraHeader) {
29
+ if (actions.length === 0)
30
+ return '_No actions found._';
31
+ const header = `| # | Title | Initiative | Assignee | Priority | Due | Status${extraHeader ? ` | ${extraHeader}` : ''} |`;
32
+ const sep = `|---|-------|-----------|----------|----------|-----|--------${extraHeader ? '|------' : ''}|`;
33
+ const rows = actions.map((a, i) => {
34
+ const assignee = a.assignees.map(u => u.name).join(', ') || 'Unassigned';
35
+ const init = a.initiative?.title ?? 'Standalone';
36
+ const extra = extraCol ? ` | ${extraCol(a)}` : '';
37
+ return `| ${i + 1} | ${a.title} | ${init} | ${assignee} | ${a.priority} | ${fmtDate(a.dueDate)} | ${a.status}${extra} |`;
38
+ });
39
+ return [header, sep, ...rows].join('\n');
40
+ }
41
+ export function fmtInitiativeTable(initiatives) {
42
+ if (initiatives.length === 0)
43
+ return '_No initiatives found._';
44
+ const header = '| # | Title | Status | Priority | Progress | Actions | Due |';
45
+ const sep = '|---|-------|--------|----------|----------|---------|-----|';
46
+ const rows = initiatives.map((i, idx) => {
47
+ const count = i._count?.actions ?? '?';
48
+ return `| ${idx + 1} | ${i.title} | ${i.status} | ${i.priority} | ${i.progress}% | ${count} | ${fmtDate(i.dueDate)} |`;
49
+ });
50
+ return [header, sep, ...rows].join('\n');
51
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/build/index.js ADDED
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { auth } from './auth.js';
6
+ import { reportingToolSchemas, handleReportingTool } from './tools/reporting.js';
7
+ import { actionToolSchemas, handleActionTool } from './tools/actions.js';
8
+ import { initiativeToolSchemas, handleInitiativeTool } from './tools/initiatives.js';
9
+ import { clearCache } from './resolve.js';
10
+ const server = new McpServer({
11
+ name: 'eassist-mcp',
12
+ version: '1.0.0',
13
+ });
14
+ const allTools = [
15
+ ...reportingToolSchemas,
16
+ ...actionToolSchemas,
17
+ ...initiativeToolSchemas,
18
+ {
19
+ name: 'whoami',
20
+ description: 'Returns the currently authenticated user and the API URL in use.',
21
+ inputSchema: { type: 'object', properties: {} },
22
+ },
23
+ {
24
+ name: 'logout',
25
+ description: 'Log out of EAssist — clears stored tokens. Next tool call will prompt for login again.',
26
+ inputSchema: { type: 'object', properties: {} },
27
+ },
28
+ {
29
+ name: 'refresh_cache',
30
+ description: 'Clear the in-memory member and initiative cache so the next lookup fetches fresh data.',
31
+ inputSchema: { type: 'object', properties: {} },
32
+ },
33
+ ];
34
+ function buildZodShape(schema) {
35
+ const shape = {};
36
+ if (!schema.properties)
37
+ return shape;
38
+ for (const [key, prop] of Object.entries(schema.properties)) {
39
+ if (prop.enum) {
40
+ shape[key] = z.enum(prop.enum).optional();
41
+ }
42
+ else if (prop.type === 'number') {
43
+ shape[key] = z.number().optional();
44
+ }
45
+ else if (prop.type === 'array') {
46
+ shape[key] = z.array(z.string()).optional();
47
+ }
48
+ else {
49
+ shape[key] = z.string().optional();
50
+ }
51
+ }
52
+ return shape;
53
+ }
54
+ const reportingNames = new Set(reportingToolSchemas.map(t => t.name));
55
+ const actionNames = new Set(actionToolSchemas.map(t => t.name));
56
+ const initiativeNames = new Set(initiativeToolSchemas.map(t => t.name));
57
+ for (const tool of allTools) {
58
+ const zodShape = buildZodShape(tool.inputSchema);
59
+ server.tool(tool.name, tool.description, zodShape, async (args) => {
60
+ try {
61
+ const rawArgs = args;
62
+ let text;
63
+ if (reportingNames.has(tool.name)) {
64
+ text = await handleReportingTool(tool.name, rawArgs);
65
+ }
66
+ else if (actionNames.has(tool.name)) {
67
+ text = await handleActionTool(tool.name, rawArgs);
68
+ }
69
+ else if (initiativeNames.has(tool.name)) {
70
+ text = await handleInitiativeTool(tool.name, rawArgs);
71
+ }
72
+ else if (tool.name === 'whoami') {
73
+ const name = auth.getStoredName();
74
+ const apiUrl = process.env.EASSIST_API_URL ?? 'not set';
75
+ text = `**Logged in as:** ${name}\n**API:** ${apiUrl}`;
76
+ }
77
+ else if (tool.name === 'logout') {
78
+ auth.clearTokens();
79
+ text = 'Logged out. Stored tokens cleared. Next tool call will prompt for login.';
80
+ }
81
+ else if (tool.name === 'refresh_cache') {
82
+ clearCache();
83
+ text = 'Cache cleared.';
84
+ }
85
+ else {
86
+ text = `Unknown tool: ${tool.name}`;
87
+ }
88
+ return { content: [{ type: 'text', text }] };
89
+ }
90
+ catch (err) {
91
+ const msg = err instanceof Error ? err.message : String(err);
92
+ return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
93
+ }
94
+ });
95
+ }
96
+ async function main() {
97
+ const transport = new StdioServerTransport();
98
+ await server.connect(transport);
99
+ console.error('[eassist-mcp] Server started. Waiting for requests...');
100
+ }
101
+ main().catch(err => {
102
+ console.error('[eassist-mcp] Fatal error:', err);
103
+ process.exit(1);
104
+ });
@@ -0,0 +1,14 @@
1
+ export declare function fuzzyMatch(query: string, name: string): boolean;
2
+ export declare function resolveUser(name: string): Promise<{
3
+ id: string;
4
+ name: string;
5
+ } | {
6
+ error: string;
7
+ }>;
8
+ export declare function resolveInitiative(name: string): Promise<{
9
+ id: string;
10
+ title: string;
11
+ } | {
12
+ error: string;
13
+ }>;
14
+ export declare function clearCache(): void;
@@ -0,0 +1,61 @@
1
+ import { apiGet } from './client.js';
2
+ // Cache members for the session to avoid repeated API calls
3
+ let memberCache = null;
4
+ let initiativeCache = null;
5
+ async function getMembers() {
6
+ if (memberCache)
7
+ return memberCache;
8
+ const data = await apiGet('/initiatives');
9
+ const seen = new Set();
10
+ const members = [];
11
+ for (const init of data.initiatives) {
12
+ try {
13
+ const m = await apiGet(`/initiatives/${init.id}/members`);
14
+ for (const member of m.members) {
15
+ if (!seen.has(member.user.id)) {
16
+ seen.add(member.user.id);
17
+ members.push({ id: member.user.id, name: member.user.name, email: member.user.email });
18
+ }
19
+ }
20
+ }
21
+ catch { /* skip inaccessible */ }
22
+ }
23
+ memberCache = members;
24
+ return members;
25
+ }
26
+ async function getInitiatives() {
27
+ if (initiativeCache)
28
+ return initiativeCache;
29
+ const data = await apiGet('/initiatives');
30
+ initiativeCache = data.initiatives.map(i => ({ id: i.id, title: i.title }));
31
+ return initiativeCache;
32
+ }
33
+ export function fuzzyMatch(query, name) {
34
+ const q = query.toLowerCase();
35
+ const n = name.toLowerCase();
36
+ return n.includes(q) || q.includes(n);
37
+ }
38
+ export async function resolveUser(name) {
39
+ const members = await getMembers();
40
+ const matches = members.filter(m => fuzzyMatch(name, m.name) || fuzzyMatch(name, m.email));
41
+ if (matches.length === 1)
42
+ return matches[0];
43
+ if (matches.length === 0)
44
+ return { error: `No user found matching "${name}". Use list_members to see available users.` };
45
+ return { error: `Multiple users match "${name}": ${matches.map(m => m.name).join(', ')}. Please be more specific.` };
46
+ }
47
+ export async function resolveInitiative(name) {
48
+ const inits = await getInitiatives();
49
+ const matches = inits.filter(i => fuzzyMatch(name, i.title));
50
+ if (matches.length === 1)
51
+ return matches[0];
52
+ if (matches.length === 0)
53
+ return { error: `No initiative found matching "${name}". Use list_initiatives to see available initiatives.` };
54
+ if (matches.length <= 5)
55
+ return { error: `Multiple initiatives match "${name}": ${matches.map(i => i.title).join(', ')}. Please be more specific.` };
56
+ return { error: `Too many initiatives match "${name}". Please be more specific.` };
57
+ }
58
+ export function clearCache() {
59
+ memberCache = null;
60
+ initiativeCache = null;
61
+ }