@telitask/mcp-server 0.1.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/dist/auth/oauth.d.ts +3 -0
- package/dist/auth/oauth.d.ts.map +1 -0
- package/dist/auth/oauth.js +79 -0
- package/dist/auth/token-store.d.ts +12 -0
- package/dist/auth/token-store.d.ts.map +1 -0
- package/dist/auth/token-store.js +42 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +58 -0
- package/dist/lib/supabase.d.ts +8 -0
- package/dist/lib/supabase.d.ts.map +1 -0
- package/dist/lib/supabase.js +55 -0
- package/dist/lib/utils.d.ts +7 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +8 -0
- package/dist/resources/index.d.ts +3 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +48 -0
- package/dist/tools/calls.d.ts +50 -0
- package/dist/tools/calls.d.ts.map +1 -0
- package/dist/tools/calls.js +429 -0
- package/dist/tools/contacts.d.ts +20 -0
- package/dist/tools/contacts.d.ts.map +1 -0
- package/dist/tools/contacts.js +77 -0
- package/dist/tools/tasks.d.ts +32 -0
- package/dist/tools/tasks.d.ts.map +1 -0
- package/dist/tools/tasks.js +121 -0
- package/package.json +46 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../../src/auth/oauth.ts"],"names":[],"mappings":"AAGA,OAAO,EAAoB,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAKzE,wBAAsB,aAAa,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAqFjF"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as http from 'node:http';
|
|
2
|
+
import * as crypto from 'node:crypto';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import { writeCredentials } from './token-store.js';
|
|
5
|
+
const CALLBACK_PORT = 7891;
|
|
6
|
+
const CALLBACK_PATH = '/callback';
|
|
7
|
+
export async function runOAuthLogin(dashboardUrl) {
|
|
8
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const server = http.createServer(async (req, res) => {
|
|
11
|
+
const url = new URL(req.url ?? '/', `http://localhost:${CALLBACK_PORT}`);
|
|
12
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
13
|
+
res.writeHead(404);
|
|
14
|
+
res.end('Not found');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const code = url.searchParams.get('code');
|
|
18
|
+
const returnedState = url.searchParams.get('state');
|
|
19
|
+
const error = url.searchParams.get('error');
|
|
20
|
+
if (error) {
|
|
21
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
22
|
+
res.end('<html><body><h1>Authorization denied</h1><p>You can close this window.</p></body></html>');
|
|
23
|
+
server.close();
|
|
24
|
+
reject(new Error(`Authorization denied: ${error}`));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (returnedState !== state) {
|
|
28
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
29
|
+
res.end('<html><body><h1>Invalid state</h1><p>CSRF check failed.</p></body></html>');
|
|
30
|
+
server.close();
|
|
31
|
+
reject(new Error('State mismatch — possible CSRF attack'));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!code) {
|
|
35
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
36
|
+
res.end('<html><body><h1>Missing code</h1></body></html>');
|
|
37
|
+
server.close();
|
|
38
|
+
reject(new Error('No authorization code received'));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const exchangeUrl = `${dashboardUrl}/api/auth/mcp/exchange`;
|
|
43
|
+
const response = await fetch(exchangeUrl, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ code }),
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
const errData = await response.json().catch(() => ({}));
|
|
50
|
+
throw new Error(errData.error ?? `Exchange failed: ${response.status}`);
|
|
51
|
+
}
|
|
52
|
+
const credentials = (await response.json());
|
|
53
|
+
writeCredentials(credentials);
|
|
54
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
55
|
+
res.end('<html><body><h1>Authorized!</h1><p>TeliTask MCP is connected. You can close this window.</p></body></html>');
|
|
56
|
+
server.close();
|
|
57
|
+
resolve(credentials);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
res.writeHead(500, { 'Content-Type': 'text/html' });
|
|
61
|
+
res.end('<html><body><h1>Error</h1><p>Failed to exchange code.</p></body></html>');
|
|
62
|
+
server.close();
|
|
63
|
+
reject(err);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
server.listen(CALLBACK_PORT, () => {
|
|
67
|
+
const redirectUri = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
68
|
+
const authorizeUrl = new URL('/authorize', dashboardUrl);
|
|
69
|
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
|
70
|
+
authorizeUrl.searchParams.set('state', state);
|
|
71
|
+
console.error(`Opening browser for authorization...`);
|
|
72
|
+
console.error(`If it doesn't open, visit: ${authorizeUrl.toString()}`);
|
|
73
|
+
open(authorizeUrl.toString()).catch(() => {
|
|
74
|
+
console.error(`Could not open browser. Visit: ${authorizeUrl.toString()}`);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
setTimeout(() => { server.close(); reject(new Error('Auth timed out after 2 minutes')); }, 120_000);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface McpCredentials {
|
|
2
|
+
access_token: string;
|
|
3
|
+
refresh_token: string;
|
|
4
|
+
user_id: string;
|
|
5
|
+
supabase_url: string;
|
|
6
|
+
supabase_anon_key: string;
|
|
7
|
+
voice_server_url: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function readCredentials(): McpCredentials | null;
|
|
10
|
+
export declare function writeCredentials(credentials: McpCredentials): void;
|
|
11
|
+
export declare function clearCredentials(): void;
|
|
12
|
+
//# sourceMappingURL=token-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-store.d.ts","sourceRoot":"","sources":["../../src/auth/token-store.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,cAAc;IAC7B,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAiBD,wBAAgB,eAAe,IAAI,cAAc,GAAG,IAAI,CAUvD;AAED,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,cAAc,GAAG,IAAI,CAOlE;AAED,wBAAgB,gBAAgB,IAAI,IAAI,CAIvC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
const CREDENTIALS_PATH = path.join(os.homedir(), '.telitask', 'mcp-credentials.json');
|
|
5
|
+
function isValidCredentials(value) {
|
|
6
|
+
if (typeof value !== 'object' || value === null)
|
|
7
|
+
return false;
|
|
8
|
+
const obj = value;
|
|
9
|
+
return (typeof obj.access_token === 'string' &&
|
|
10
|
+
typeof obj.refresh_token === 'string' &&
|
|
11
|
+
typeof obj.user_id === 'string' &&
|
|
12
|
+
typeof obj.supabase_url === 'string' &&
|
|
13
|
+
typeof obj.supabase_anon_key === 'string' &&
|
|
14
|
+
typeof obj.voice_server_url === 'string');
|
|
15
|
+
}
|
|
16
|
+
export function readCredentials() {
|
|
17
|
+
try {
|
|
18
|
+
if (!fs.existsSync(CREDENTIALS_PATH))
|
|
19
|
+
return null;
|
|
20
|
+
const raw = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
|
|
21
|
+
const parsed = JSON.parse(raw);
|
|
22
|
+
if (!isValidCredentials(parsed))
|
|
23
|
+
return null;
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function writeCredentials(credentials) {
|
|
31
|
+
const dir = path.dirname(CREDENTIALS_PATH);
|
|
32
|
+
if (!fs.existsSync(dir)) {
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(credentials, null, 2));
|
|
36
|
+
fs.chmodSync(CREDENTIALS_PATH, 0o600);
|
|
37
|
+
}
|
|
38
|
+
export function clearCredentials() {
|
|
39
|
+
if (fs.existsSync(CREDENTIALS_PATH)) {
|
|
40
|
+
fs.unlinkSync(CREDENTIALS_PATH);
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
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 { readCredentials } from './auth/token-store.js';
|
|
5
|
+
import { runOAuthLogin } from './auth/oauth.js';
|
|
6
|
+
import { registerContactTools } from './tools/contacts.js';
|
|
7
|
+
import { registerTaskTools } from './tools/tasks.js';
|
|
8
|
+
import { registerCallTools } from './tools/calls.js';
|
|
9
|
+
import { registerResources } from './resources/index.js';
|
|
10
|
+
const DEFAULT_DASHBOARD_URL = 'https://dashboard.telitask.com';
|
|
11
|
+
async function runLogin() {
|
|
12
|
+
const dashboardUrl = process.env.TELITASK_DASHBOARD_URL ?? DEFAULT_DASHBOARD_URL;
|
|
13
|
+
console.error(`Authenticating with TeliTask at ${dashboardUrl}...`);
|
|
14
|
+
try {
|
|
15
|
+
await runOAuthLogin(dashboardUrl);
|
|
16
|
+
console.error('Successfully authenticated! You can now use TeliTask MCP.');
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
console.error(`Authentication failed: ${err instanceof Error ? err.message : err}`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function runServer() {
|
|
24
|
+
const credentials = readCredentials();
|
|
25
|
+
if (!credentials) {
|
|
26
|
+
console.error('Not authenticated. Run `telitask-mcp login` to connect your TeliTask account.');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
const server = new McpServer({ name: 'telitask', version: '0.1.0' }, {
|
|
30
|
+
instructions: 'TeliTask MCP server — manage contacts, tasks, and make phone calls. ' +
|
|
31
|
+
'Use make_call to initiate calls, schedule_call for future calls. ' +
|
|
32
|
+
'When the user mentions a contact by name, use list_contacts to find them first.',
|
|
33
|
+
});
|
|
34
|
+
registerContactTools(server);
|
|
35
|
+
registerTaskTools(server);
|
|
36
|
+
registerCallTools(server);
|
|
37
|
+
registerResources(server);
|
|
38
|
+
const transport = new StdioServerTransport();
|
|
39
|
+
await server.connect(transport);
|
|
40
|
+
}
|
|
41
|
+
const command = process.argv[2];
|
|
42
|
+
if (command === 'login') {
|
|
43
|
+
runLogin().catch((err) => {
|
|
44
|
+
console.error('Login error:', err);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
else if (command === 'logout') {
|
|
49
|
+
const { clearCredentials } = await import('./auth/token-store.js');
|
|
50
|
+
clearCredentials();
|
|
51
|
+
console.error('Logged out. Credentials removed.');
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
runServer().catch((err) => {
|
|
55
|
+
console.error('Server error:', err);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import type { Database } from '@telitask/shared';
|
|
3
|
+
export declare function getAuthenticatedClient(): SupabaseClient<Database>;
|
|
4
|
+
export declare function refreshSessionIfNeeded(): Promise<void>;
|
|
5
|
+
export declare function getVoiceServerUrl(): string;
|
|
6
|
+
export declare function getUserId(): string;
|
|
7
|
+
export declare function getAccessToken(): string;
|
|
8
|
+
//# sourceMappingURL=supabase.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"supabase.d.ts","sourceRoot":"","sources":["../../src/lib/supabase.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC1E,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAMjD,wBAAgB,sBAAsB,IAAI,cAAc,CAAC,QAAQ,CAAC,CAgBjE;AAED,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC,CAmB5D;AAED,wBAAgB,iBAAiB,IAAI,MAAM,CAI1C;AAED,wBAAgB,SAAS,IAAI,MAAM,CAIlC;AAED,wBAAgB,cAAc,IAAI,MAAM,CAIvC"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
import { readCredentials, writeCredentials } from '../auth/token-store.js';
|
|
3
|
+
let cachedClient = null;
|
|
4
|
+
let cachedCredentials = null;
|
|
5
|
+
export function getAuthenticatedClient() {
|
|
6
|
+
const credentials = readCredentials();
|
|
7
|
+
if (!credentials)
|
|
8
|
+
throw new Error('Not authenticated. Run `telitask-mcp login` first.');
|
|
9
|
+
if (cachedClient && cachedCredentials && cachedCredentials.access_token === credentials.access_token) {
|
|
10
|
+
return cachedClient;
|
|
11
|
+
}
|
|
12
|
+
const client = createClient(credentials.supabase_url, credentials.supabase_anon_key, {
|
|
13
|
+
global: { headers: { Authorization: `Bearer ${credentials.access_token}` } },
|
|
14
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
15
|
+
});
|
|
16
|
+
cachedClient = client;
|
|
17
|
+
cachedCredentials = credentials;
|
|
18
|
+
return client;
|
|
19
|
+
}
|
|
20
|
+
export async function refreshSessionIfNeeded() {
|
|
21
|
+
const credentials = readCredentials();
|
|
22
|
+
if (!credentials)
|
|
23
|
+
return;
|
|
24
|
+
const client = createClient(credentials.supabase_url, credentials.supabase_anon_key, {
|
|
25
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
26
|
+
});
|
|
27
|
+
const { data, error } = await client.auth.setSession({
|
|
28
|
+
access_token: credentials.access_token,
|
|
29
|
+
refresh_token: credentials.refresh_token,
|
|
30
|
+
});
|
|
31
|
+
if (error || !data.session)
|
|
32
|
+
return;
|
|
33
|
+
if (data.session.access_token !== credentials.access_token) {
|
|
34
|
+
writeCredentials({ ...credentials, access_token: data.session.access_token, refresh_token: data.session.refresh_token });
|
|
35
|
+
cachedClient = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function getVoiceServerUrl() {
|
|
39
|
+
const credentials = readCredentials();
|
|
40
|
+
if (!credentials?.voice_server_url)
|
|
41
|
+
throw new Error('Not authenticated. Run `telitask-mcp login` first.');
|
|
42
|
+
return credentials.voice_server_url;
|
|
43
|
+
}
|
|
44
|
+
export function getUserId() {
|
|
45
|
+
const credentials = readCredentials();
|
|
46
|
+
if (!credentials?.user_id)
|
|
47
|
+
throw new Error('Not authenticated. Run `telitask-mcp login` first.');
|
|
48
|
+
return credentials.user_id;
|
|
49
|
+
}
|
|
50
|
+
export function getAccessToken() {
|
|
51
|
+
const credentials = readCredentials();
|
|
52
|
+
if (!credentials?.access_token)
|
|
53
|
+
throw new Error('Not authenticated. Run `telitask-mcp login` first.');
|
|
54
|
+
return credentials.access_token;
|
|
55
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape special characters in a string used inside a PostgreSQL ILIKE pattern.
|
|
3
|
+
* `%` and `_` are wildcards in LIKE/ILIKE; backslash-escape them so they are
|
|
4
|
+
* treated as literal characters.
|
|
5
|
+
*/
|
|
6
|
+
export declare function escapeIlikePattern(input: string): string;
|
|
7
|
+
//# sourceMappingURL=utils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/lib/utils.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAExD"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape special characters in a string used inside a PostgreSQL ILIKE pattern.
|
|
3
|
+
* `%` and `_` are wildcards in LIKE/ILIKE; backslash-escape them so they are
|
|
4
|
+
* treated as literal characters.
|
|
5
|
+
*/
|
|
6
|
+
export function escapeIlikePattern(input) {
|
|
7
|
+
return input.replace(/[%_\\]/g, '\\$&');
|
|
8
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/resources/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGzE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAwEzD"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getAuthenticatedClient, refreshSessionIfNeeded, getUserId } from '../lib/supabase.js';
|
|
2
|
+
export function registerResources(server) {
|
|
3
|
+
server.resource('user-profile', 'telitask://user/profile', { title: 'User Profile', mimeType: 'application/json' }, async (uri) => {
|
|
4
|
+
await refreshSessionIfNeeded();
|
|
5
|
+
const supabase = getAuthenticatedClient();
|
|
6
|
+
const userId = getUserId();
|
|
7
|
+
const { data, error } = await supabase
|
|
8
|
+
.from('users')
|
|
9
|
+
.select('id, full_name, email, phone_number, timezone, subscription_tier, subscription_status, default_persona_id, created_at')
|
|
10
|
+
.eq('id', userId)
|
|
11
|
+
.single();
|
|
12
|
+
if (error || !data) {
|
|
13
|
+
return { contents: [{ uri: uri.href, text: JSON.stringify({ error: 'Could not load profile' }) }] };
|
|
14
|
+
}
|
|
15
|
+
return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2) }] };
|
|
16
|
+
});
|
|
17
|
+
server.resource('contacts-directory', 'telitask://contacts', { title: 'Contact Directory', mimeType: 'application/json' }, async (uri) => {
|
|
18
|
+
await refreshSessionIfNeeded();
|
|
19
|
+
const supabase = getAuthenticatedClient();
|
|
20
|
+
const userId = getUserId();
|
|
21
|
+
const { data, error } = await supabase
|
|
22
|
+
.from('contacts')
|
|
23
|
+
.select('id, full_name, phone_number, email, notes, last_contacted_at')
|
|
24
|
+
.eq('user_id', userId)
|
|
25
|
+
.order('full_name', { ascending: true })
|
|
26
|
+
.limit(100);
|
|
27
|
+
if (error) {
|
|
28
|
+
return { contents: [{ uri: uri.href, text: JSON.stringify({ error: error.message }) }] };
|
|
29
|
+
}
|
|
30
|
+
return { contents: [{ uri: uri.href, text: JSON.stringify(data ?? [], null, 2) }] };
|
|
31
|
+
});
|
|
32
|
+
server.resource('pending-tasks', 'telitask://tasks', { title: 'Pending Tasks', mimeType: 'application/json' }, async (uri) => {
|
|
33
|
+
await refreshSessionIfNeeded();
|
|
34
|
+
const supabase = getAuthenticatedClient();
|
|
35
|
+
const userId = getUserId();
|
|
36
|
+
const { data, error } = await supabase
|
|
37
|
+
.from('tasks')
|
|
38
|
+
.select('id, title, notes, status, priority, due_at, reminder_at, created_at')
|
|
39
|
+
.eq('user_id', userId)
|
|
40
|
+
.eq('status', 'pending')
|
|
41
|
+
.order('due_at', { ascending: true, nullsFirst: false })
|
|
42
|
+
.limit(100);
|
|
43
|
+
if (error) {
|
|
44
|
+
return { contents: [{ uri: uri.href, text: JSON.stringify({ error: error.message }) }] };
|
|
45
|
+
}
|
|
46
|
+
return { contents: [{ uri: uri.href, text: JSON.stringify(data ?? [], null, 2) }] };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
export declare function handleMakeCall(params: {
|
|
3
|
+
contact_id?: string;
|
|
4
|
+
contact_name?: string;
|
|
5
|
+
brief: string;
|
|
6
|
+
persona_id?: string;
|
|
7
|
+
wait_for_result?: boolean;
|
|
8
|
+
}): Promise<{
|
|
9
|
+
type: 'text';
|
|
10
|
+
text: string;
|
|
11
|
+
}[]>;
|
|
12
|
+
export declare function handleScheduleCall(params: {
|
|
13
|
+
contact_id?: string;
|
|
14
|
+
contact_name?: string;
|
|
15
|
+
brief: string;
|
|
16
|
+
scheduled_at: string;
|
|
17
|
+
persona_id?: string;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
type: 'text';
|
|
20
|
+
text: string;
|
|
21
|
+
}[]>;
|
|
22
|
+
export declare function handleCancelCall(params: {
|
|
23
|
+
call_id: string;
|
|
24
|
+
}): Promise<{
|
|
25
|
+
type: 'text';
|
|
26
|
+
text: string;
|
|
27
|
+
}[]>;
|
|
28
|
+
export declare function handleListCalls(params: {
|
|
29
|
+
limit?: number;
|
|
30
|
+
status?: string;
|
|
31
|
+
}): Promise<{
|
|
32
|
+
type: 'text';
|
|
33
|
+
text: string;
|
|
34
|
+
}[]>;
|
|
35
|
+
export declare function handleGetCall(params: {
|
|
36
|
+
call_id: string;
|
|
37
|
+
}): Promise<{
|
|
38
|
+
type: 'text';
|
|
39
|
+
text: string;
|
|
40
|
+
}[]>;
|
|
41
|
+
export declare function handleCallMe(params: {
|
|
42
|
+
brief?: string;
|
|
43
|
+
persona_id?: string;
|
|
44
|
+
wait_for_result?: boolean;
|
|
45
|
+
}): Promise<{
|
|
46
|
+
type: 'text';
|
|
47
|
+
text: string;
|
|
48
|
+
}[]>;
|
|
49
|
+
export declare function registerCallTools(server: McpServer): void;
|
|
50
|
+
//# sourceMappingURL=calls.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"calls.d.ts","sourceRoot":"","sources":["../../src/tools/calls.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAiJzE,wBAAsB,cAAc,CAAC,MAAM,EAAE;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAAC,CA+E5C;AAED,wBAAsB,kBAAkB,CAAC,MAAM,EAAE;IAC/C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAAC,CAwC5C;AAED,wBAAsB,gBAAgB,CAAC,MAAM,EAAE;IAC7C,OAAO,EAAE,MAAM,CAAC;CACjB,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAAC,CAgC5C;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAAC,CAuC5C;AAED,wBAAsB,aAAa,CAAC,MAAM,EAAE;IAC1C,OAAO,EAAE,MAAM,CAAC;CACjB,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAAC,CAwC5C;AAED,wBAAsB,YAAY,CAAC,MAAM,EAAE;IACzC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAAC,CA2E5C;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA6EzD"}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getAuthenticatedClient, refreshSessionIfNeeded, getUserId, getVoiceServerUrl, getAccessToken, } from '../lib/supabase.js';
|
|
3
|
+
import { escapeIlikePattern } from '../lib/utils.js';
|
|
4
|
+
const POLL_INTERVAL_MS = 30_000; // 30 seconds
|
|
5
|
+
const MAX_POLL_DURATION_MS = 10 * 60_000; // 10 minutes
|
|
6
|
+
async function waitForCallCompletion(scheduledCallId) {
|
|
7
|
+
const supabase = getAuthenticatedClient();
|
|
8
|
+
const userId = getUserId();
|
|
9
|
+
const startTime = Date.now();
|
|
10
|
+
while (Date.now() - startTime < MAX_POLL_DURATION_MS) {
|
|
11
|
+
// Check the scheduled call for its triggered_call_id
|
|
12
|
+
const { data: scheduledCall } = await supabase
|
|
13
|
+
.from('scheduled_calls')
|
|
14
|
+
.select('status, triggered_call_id')
|
|
15
|
+
.eq('id', scheduledCallId)
|
|
16
|
+
.eq('user_id', userId)
|
|
17
|
+
.single();
|
|
18
|
+
if (!scheduledCall)
|
|
19
|
+
return null;
|
|
20
|
+
// If the scheduled call failed or was cancelled, return early
|
|
21
|
+
if (scheduledCall.status === 'failed' || scheduledCall.status === 'cancelled') {
|
|
22
|
+
return { status: scheduledCall.status, summary: null, transcript: null, duration_seconds: null, triggered_call_id: null };
|
|
23
|
+
}
|
|
24
|
+
// If the call was triggered, check the actual call record
|
|
25
|
+
if (scheduledCall.triggered_call_id) {
|
|
26
|
+
const { data: call } = await supabase
|
|
27
|
+
.from('calls')
|
|
28
|
+
.select('status, call_summary, transcript, duration_seconds')
|
|
29
|
+
.eq('id', scheduledCall.triggered_call_id)
|
|
30
|
+
.eq('user_id', userId)
|
|
31
|
+
.single();
|
|
32
|
+
if (call && (call.status === 'failed' || call.status === 'no_answer')) {
|
|
33
|
+
return {
|
|
34
|
+
status: call.status,
|
|
35
|
+
summary: call.call_summary,
|
|
36
|
+
transcript: call.transcript,
|
|
37
|
+
duration_seconds: call.duration_seconds,
|
|
38
|
+
triggered_call_id: scheduledCall.triggered_call_id,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// Call completed — wait for summary to be generated (async post-call processing)
|
|
42
|
+
if (call && call.status === 'completed') {
|
|
43
|
+
if (call.call_summary && call.transcript) {
|
|
44
|
+
return {
|
|
45
|
+
status: call.status,
|
|
46
|
+
summary: call.call_summary,
|
|
47
|
+
transcript: call.transcript,
|
|
48
|
+
duration_seconds: call.duration_seconds,
|
|
49
|
+
triggered_call_id: scheduledCall.triggered_call_id,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// Summary not ready yet — poll faster since call is done
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
58
|
+
}
|
|
59
|
+
return null; // Timed out
|
|
60
|
+
}
|
|
61
|
+
async function resolveContact(contactId, contactName) {
|
|
62
|
+
const supabase = getAuthenticatedClient();
|
|
63
|
+
const userId = getUserId();
|
|
64
|
+
if (contactId) {
|
|
65
|
+
const { data, error } = await supabase
|
|
66
|
+
.from('contacts')
|
|
67
|
+
.select('id, full_name, phone_number')
|
|
68
|
+
.eq('id', contactId)
|
|
69
|
+
.eq('user_id', userId)
|
|
70
|
+
.single();
|
|
71
|
+
if (error || !data) {
|
|
72
|
+
return [{ type: 'text', text: `Contact not found with ID: ${contactId}` }];
|
|
73
|
+
}
|
|
74
|
+
if (!data.phone_number) {
|
|
75
|
+
return [{ type: 'text', text: `Contact "${data.full_name}" has no phone number on file.` }];
|
|
76
|
+
}
|
|
77
|
+
return { id: data.id, full_name: data.full_name, phone_number: data.phone_number };
|
|
78
|
+
}
|
|
79
|
+
if (contactName) {
|
|
80
|
+
const { data, error } = await supabase
|
|
81
|
+
.from('contacts')
|
|
82
|
+
.select('id, full_name, phone_number')
|
|
83
|
+
.eq('user_id', userId)
|
|
84
|
+
.ilike('full_name', `%${escapeIlikePattern(contactName)}%`)
|
|
85
|
+
.limit(5);
|
|
86
|
+
if (error || !data || data.length === 0) {
|
|
87
|
+
return [{ type: 'text', text: `No contacts found matching "${contactName}".` }];
|
|
88
|
+
}
|
|
89
|
+
if (data.length > 1) {
|
|
90
|
+
const list = data.map((c) => `- **${c.full_name}** (ID: ${c.id}) — ${c.phone_number ?? 'no phone'}`).join('\n');
|
|
91
|
+
return [{ type: 'text', text: `Multiple contacts match "${contactName}". Please specify the contact_id:\n\n${list}` }];
|
|
92
|
+
}
|
|
93
|
+
const contact = data[0];
|
|
94
|
+
if (!contact.phone_number) {
|
|
95
|
+
return [{ type: 'text', text: `Contact "${contact.full_name}" has no phone number on file.` }];
|
|
96
|
+
}
|
|
97
|
+
return { id: contact.id, full_name: contact.full_name, phone_number: contact.phone_number };
|
|
98
|
+
}
|
|
99
|
+
return [{ type: 'text', text: 'Either contact_id or contact_name must be provided.' }];
|
|
100
|
+
}
|
|
101
|
+
async function resolvePersonaId(explicitId, userId) {
|
|
102
|
+
if (explicitId)
|
|
103
|
+
return explicitId;
|
|
104
|
+
const supabase = getAuthenticatedClient();
|
|
105
|
+
const { data: user } = await supabase
|
|
106
|
+
.from('users')
|
|
107
|
+
.select('default_persona_id')
|
|
108
|
+
.eq('id', userId)
|
|
109
|
+
.single();
|
|
110
|
+
return user?.default_persona_id ?? undefined;
|
|
111
|
+
}
|
|
112
|
+
export async function handleMakeCall(params) {
|
|
113
|
+
await refreshSessionIfNeeded();
|
|
114
|
+
const userId = getUserId();
|
|
115
|
+
const voiceServerUrl = getVoiceServerUrl();
|
|
116
|
+
const accessToken = getAccessToken();
|
|
117
|
+
const contactResult = await resolveContact(params.contact_id, params.contact_name);
|
|
118
|
+
if (Array.isArray(contactResult))
|
|
119
|
+
return contactResult;
|
|
120
|
+
const personaId = await resolvePersonaId(params.persona_id, userId);
|
|
121
|
+
if (!personaId) {
|
|
122
|
+
return [{ type: 'text', text: 'No persona specified and no default persona set. Please provide a persona_id.' }];
|
|
123
|
+
}
|
|
124
|
+
// Schedule an on_behalf call 2 minutes from now
|
|
125
|
+
// The scheduled calls system handles on_behalf calls with contact context and purpose
|
|
126
|
+
// Format as local time (YYYY-MM-DDTHH:mm:ss) — the voice server expects local, not UTC
|
|
127
|
+
const d = new Date(Date.now() + 2 * 60_000);
|
|
128
|
+
const localTime = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}T${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
|
|
129
|
+
const response = await fetch(`${voiceServerUrl}/api/scheduled-calls`, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: {
|
|
132
|
+
'Content-Type': 'application/json',
|
|
133
|
+
Authorization: `Bearer ${accessToken}`,
|
|
134
|
+
},
|
|
135
|
+
body: JSON.stringify({
|
|
136
|
+
personaId,
|
|
137
|
+
callType: 'on_behalf',
|
|
138
|
+
contactId: contactResult.id,
|
|
139
|
+
purpose: params.brief,
|
|
140
|
+
scheduledAt: localTime,
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
const errBody = await response.text().catch(() => '');
|
|
145
|
+
return [{ type: 'text', text: `Failed to initiate call: ${response.status} ${errBody}` }];
|
|
146
|
+
}
|
|
147
|
+
const result = await response.json();
|
|
148
|
+
const scheduledCallId = result.id;
|
|
149
|
+
if (!params.wait_for_result || !scheduledCallId) {
|
|
150
|
+
return [{
|
|
151
|
+
type: 'text',
|
|
152
|
+
text: `On-behalf call scheduled to **${contactResult.full_name}** (${contactResult.phone_number}) in ~1 minute.\nBrief: ${params.brief}\nScheduled call ID: ${scheduledCallId ?? 'pending'}`,
|
|
153
|
+
}];
|
|
154
|
+
}
|
|
155
|
+
// Wait for the call to complete and return results
|
|
156
|
+
const callResult = await waitForCallCompletion(scheduledCallId);
|
|
157
|
+
if (!callResult) {
|
|
158
|
+
return [{
|
|
159
|
+
type: 'text',
|
|
160
|
+
text: `Call to **${contactResult.full_name}** was scheduled but timed out waiting for results (10 min max).\nScheduled call ID: ${scheduledCallId}\nUse get_call to check the results later.`,
|
|
161
|
+
}];
|
|
162
|
+
}
|
|
163
|
+
if (callResult.status === 'failed' || callResult.status === 'cancelled') {
|
|
164
|
+
return [{
|
|
165
|
+
type: 'text',
|
|
166
|
+
text: `Call to **${contactResult.full_name}** ${callResult.status}.\nScheduled call ID: ${scheduledCallId}`,
|
|
167
|
+
}];
|
|
168
|
+
}
|
|
169
|
+
const duration = callResult.duration_seconds ? `${Math.round(callResult.duration_seconds / 60)} min` : 'unknown';
|
|
170
|
+
const parts = [
|
|
171
|
+
`## Call Completed — ${contactResult.full_name}`,
|
|
172
|
+
`**Status:** ${callResult.status} | **Duration:** ${duration}`,
|
|
173
|
+
`**Brief:** ${params.brief}`,
|
|
174
|
+
];
|
|
175
|
+
if (callResult.summary)
|
|
176
|
+
parts.push(`\n### Summary\n${callResult.summary}`);
|
|
177
|
+
if (callResult.transcript)
|
|
178
|
+
parts.push(`\n### Transcript\n${callResult.transcript}`);
|
|
179
|
+
if (callResult.triggered_call_id)
|
|
180
|
+
parts.push(`\nCall ID: ${callResult.triggered_call_id}`);
|
|
181
|
+
return [{ type: 'text', text: parts.join('\n') }];
|
|
182
|
+
}
|
|
183
|
+
export async function handleScheduleCall(params) {
|
|
184
|
+
await refreshSessionIfNeeded();
|
|
185
|
+
const userId = getUserId();
|
|
186
|
+
const voiceServerUrl = getVoiceServerUrl();
|
|
187
|
+
const accessToken = getAccessToken();
|
|
188
|
+
const contactResult = await resolveContact(params.contact_id, params.contact_name);
|
|
189
|
+
if (Array.isArray(contactResult))
|
|
190
|
+
return contactResult;
|
|
191
|
+
const personaId = await resolvePersonaId(params.persona_id, userId);
|
|
192
|
+
if (!personaId) {
|
|
193
|
+
return [{ type: 'text', text: 'No persona specified and no default persona set. Please provide a persona_id.' }];
|
|
194
|
+
}
|
|
195
|
+
const response = await fetch(`${voiceServerUrl}/api/scheduled-calls`, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: {
|
|
198
|
+
'Content-Type': 'application/json',
|
|
199
|
+
Authorization: `Bearer ${accessToken}`,
|
|
200
|
+
},
|
|
201
|
+
body: JSON.stringify({
|
|
202
|
+
personaId,
|
|
203
|
+
callType: 'on_behalf',
|
|
204
|
+
contactId: contactResult.id,
|
|
205
|
+
purpose: params.brief,
|
|
206
|
+
scheduledAt: params.scheduled_at,
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
const errBody = await response.text().catch(() => '');
|
|
211
|
+
return [{ type: 'text', text: `Failed to schedule call: ${response.status} ${errBody}` }];
|
|
212
|
+
}
|
|
213
|
+
const result = await response.json();
|
|
214
|
+
return [{
|
|
215
|
+
type: 'text',
|
|
216
|
+
text: `Call scheduled to **${contactResult.full_name}** at ${params.scheduled_at}.\nBrief: ${params.brief}\nScheduled call ID: ${result.id ?? 'pending'}`,
|
|
217
|
+
}];
|
|
218
|
+
}
|
|
219
|
+
export async function handleCancelCall(params) {
|
|
220
|
+
await refreshSessionIfNeeded();
|
|
221
|
+
const voiceServerUrl = getVoiceServerUrl();
|
|
222
|
+
const accessToken = getAccessToken();
|
|
223
|
+
// Try cancelling an active call first
|
|
224
|
+
const cancelResponse = await fetch(`${voiceServerUrl}/api/calls/cancel`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: {
|
|
227
|
+
'Content-Type': 'application/json',
|
|
228
|
+
Authorization: `Bearer ${accessToken}`,
|
|
229
|
+
},
|
|
230
|
+
body: JSON.stringify({ callId: params.call_id }),
|
|
231
|
+
});
|
|
232
|
+
if (cancelResponse.ok) {
|
|
233
|
+
return [{ type: 'text', text: `Call ${params.call_id} cancelled successfully.` }];
|
|
234
|
+
}
|
|
235
|
+
// Try cancelling a scheduled call
|
|
236
|
+
const deleteResponse = await fetch(`${voiceServerUrl}/api/scheduled-calls/${params.call_id}`, {
|
|
237
|
+
method: 'DELETE',
|
|
238
|
+
headers: {
|
|
239
|
+
Authorization: `Bearer ${accessToken}`,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
if (deleteResponse.ok) {
|
|
243
|
+
return [{ type: 'text', text: `Scheduled call ${params.call_id} cancelled successfully.` }];
|
|
244
|
+
}
|
|
245
|
+
return [{ type: 'text', text: `Could not cancel call ${params.call_id}. It may have already ended or the ID is invalid.` }];
|
|
246
|
+
}
|
|
247
|
+
export async function handleListCalls(params) {
|
|
248
|
+
await refreshSessionIfNeeded();
|
|
249
|
+
const supabase = getAuthenticatedClient();
|
|
250
|
+
const userId = getUserId();
|
|
251
|
+
const limit = params.limit ?? 10;
|
|
252
|
+
let query = supabase
|
|
253
|
+
.from('calls')
|
|
254
|
+
.select('id, status, phone_number, purpose, call_summary, duration_seconds, started_at, ended_at, created_at, contact_id')
|
|
255
|
+
.eq('user_id', userId)
|
|
256
|
+
.order('created_at', { ascending: false })
|
|
257
|
+
.limit(limit);
|
|
258
|
+
if (params.status) {
|
|
259
|
+
query = query.eq('status', params.status);
|
|
260
|
+
}
|
|
261
|
+
const { data, error } = await query;
|
|
262
|
+
if (error) {
|
|
263
|
+
return [{ type: 'text', text: `Error listing calls: ${error.message}` }];
|
|
264
|
+
}
|
|
265
|
+
if (!data || data.length === 0) {
|
|
266
|
+
return [{ type: 'text', text: 'No calls found.' }];
|
|
267
|
+
}
|
|
268
|
+
const lines = data.map((c) => {
|
|
269
|
+
const parts = [`**${c.status}** — ${c.phone_number ?? 'unknown number'}`];
|
|
270
|
+
if (c.purpose)
|
|
271
|
+
parts.push(`Purpose: ${c.purpose}`);
|
|
272
|
+
if (c.call_summary)
|
|
273
|
+
parts.push(`Summary: ${c.call_summary}`);
|
|
274
|
+
if (c.duration_seconds)
|
|
275
|
+
parts.push(`Duration: ${c.duration_seconds}s`);
|
|
276
|
+
if (c.started_at)
|
|
277
|
+
parts.push(`Started: ${c.started_at}`);
|
|
278
|
+
parts.push(`ID: ${c.id}`);
|
|
279
|
+
return parts.join(' | ');
|
|
280
|
+
});
|
|
281
|
+
return [{ type: 'text', text: `Found ${data.length} call(s):\n\n${lines.join('\n')}` }];
|
|
282
|
+
}
|
|
283
|
+
export async function handleGetCall(params) {
|
|
284
|
+
await refreshSessionIfNeeded();
|
|
285
|
+
const supabase = getAuthenticatedClient();
|
|
286
|
+
const userId = getUserId();
|
|
287
|
+
const { data, error } = await supabase
|
|
288
|
+
.from('calls')
|
|
289
|
+
.select('*')
|
|
290
|
+
.eq('id', params.call_id)
|
|
291
|
+
.eq('user_id', userId)
|
|
292
|
+
.single();
|
|
293
|
+
if (error || !data) {
|
|
294
|
+
return [{ type: 'text', text: `Call not found: ${params.call_id}` }];
|
|
295
|
+
}
|
|
296
|
+
const parts = [
|
|
297
|
+
`# Call Details`,
|
|
298
|
+
`**Status:** ${data.status}`,
|
|
299
|
+
`**Phone:** ${data.phone_number ?? 'N/A'}`,
|
|
300
|
+
`**Direction:** ${data.direction}`,
|
|
301
|
+
`**Purpose:** ${data.purpose ?? 'N/A'}`,
|
|
302
|
+
`**Duration:** ${data.duration_seconds ? `${data.duration_seconds}s` : 'N/A'}`,
|
|
303
|
+
`**Started:** ${data.started_at ?? 'N/A'}`,
|
|
304
|
+
`**Ended:** ${data.ended_at ?? 'N/A'}`,
|
|
305
|
+
];
|
|
306
|
+
if (data.call_summary) {
|
|
307
|
+
parts.push(`\n## Summary\n${data.call_summary}`);
|
|
308
|
+
}
|
|
309
|
+
if (data.summary) {
|
|
310
|
+
parts.push(`\n## AI Summary\n${data.summary}`);
|
|
311
|
+
}
|
|
312
|
+
if (data.transcript) {
|
|
313
|
+
parts.push(`\n## Transcript\n${data.transcript}`);
|
|
314
|
+
}
|
|
315
|
+
parts.push(`\n**Call ID:** ${data.id}`);
|
|
316
|
+
return [{ type: 'text', text: parts.join('\n') }];
|
|
317
|
+
}
|
|
318
|
+
export async function handleCallMe(params) {
|
|
319
|
+
await refreshSessionIfNeeded();
|
|
320
|
+
const userId = getUserId();
|
|
321
|
+
const voiceServerUrl = getVoiceServerUrl();
|
|
322
|
+
const accessToken = getAccessToken();
|
|
323
|
+
const personaId = await resolvePersonaId(params.persona_id, userId);
|
|
324
|
+
if (!personaId) {
|
|
325
|
+
return [{ type: 'text', text: 'No persona specified and no default persona set. Please provide a persona_id.' }];
|
|
326
|
+
}
|
|
327
|
+
// Schedule a direct call to the user's phone 10 seconds from now
|
|
328
|
+
// Using scheduled calls so we can pass a purpose/brief — BullMQ picks it up almost instantly
|
|
329
|
+
const d = new Date(Date.now() + 10_000);
|
|
330
|
+
const localTime = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}T${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
|
|
331
|
+
const body = {
|
|
332
|
+
personaId,
|
|
333
|
+
callType: 'direct',
|
|
334
|
+
scheduledAt: localTime,
|
|
335
|
+
};
|
|
336
|
+
if (params.brief)
|
|
337
|
+
body.purpose = params.brief;
|
|
338
|
+
const response = await fetch(`${voiceServerUrl}/api/scheduled-calls`, {
|
|
339
|
+
method: 'POST',
|
|
340
|
+
headers: {
|
|
341
|
+
'Content-Type': 'application/json',
|
|
342
|
+
Authorization: `Bearer ${accessToken}`,
|
|
343
|
+
},
|
|
344
|
+
body: JSON.stringify(body),
|
|
345
|
+
});
|
|
346
|
+
if (!response.ok) {
|
|
347
|
+
const errBody = await response.text().catch(() => '');
|
|
348
|
+
return [{ type: 'text', text: `Failed to initiate call: ${response.status} ${errBody}` }];
|
|
349
|
+
}
|
|
350
|
+
const result = await response.json();
|
|
351
|
+
const scheduledCallId = result.id;
|
|
352
|
+
const briefMsg = params.brief ? `\nBrief: ${params.brief}` : '';
|
|
353
|
+
if (!params.wait_for_result || !scheduledCallId) {
|
|
354
|
+
return [{
|
|
355
|
+
type: 'text',
|
|
356
|
+
text: `Your AI assistant will call your phone shortly.${briefMsg}\nScheduled call ID: ${scheduledCallId ?? 'pending'}`,
|
|
357
|
+
}];
|
|
358
|
+
}
|
|
359
|
+
const callResult = await waitForCallCompletion(scheduledCallId);
|
|
360
|
+
if (!callResult) {
|
|
361
|
+
return [{
|
|
362
|
+
type: 'text',
|
|
363
|
+
text: `Call was scheduled but timed out waiting for results (10 min max).${briefMsg}\nScheduled call ID: ${scheduledCallId}\nUse get_call to check later.`,
|
|
364
|
+
}];
|
|
365
|
+
}
|
|
366
|
+
if (callResult.status === 'failed' || callResult.status === 'cancelled') {
|
|
367
|
+
return [{
|
|
368
|
+
type: 'text',
|
|
369
|
+
text: `Call ${callResult.status}.${briefMsg}\nScheduled call ID: ${scheduledCallId}`,
|
|
370
|
+
}];
|
|
371
|
+
}
|
|
372
|
+
const duration = callResult.duration_seconds ? `${Math.round(callResult.duration_seconds / 60)} min` : 'unknown';
|
|
373
|
+
const parts = [
|
|
374
|
+
`## Call Completed`,
|
|
375
|
+
`**Status:** ${callResult.status} | **Duration:** ${duration}`,
|
|
376
|
+
];
|
|
377
|
+
if (params.brief)
|
|
378
|
+
parts.push(`**Brief:** ${params.brief}`);
|
|
379
|
+
if (callResult.summary)
|
|
380
|
+
parts.push(`\n### Summary\n${callResult.summary}`);
|
|
381
|
+
if (callResult.transcript)
|
|
382
|
+
parts.push(`\n### Transcript\n${callResult.transcript}`);
|
|
383
|
+
if (callResult.triggered_call_id)
|
|
384
|
+
parts.push(`\nCall ID: ${callResult.triggered_call_id}`);
|
|
385
|
+
return [{ type: 'text', text: parts.join('\n') }];
|
|
386
|
+
}
|
|
387
|
+
export function registerCallTools(server) {
|
|
388
|
+
server.tool('call_me', 'Call the user\'s own phone number. The AI assistant will call with a specific topic/brief. Use when: (1) you need the user\'s input on a decision, (2) you want to brief them on results, (3) the user says "call me". Always provide a brief so the AI knows what to discuss.', {
|
|
389
|
+
brief: z.string().optional().describe('What the AI should discuss on the call — e.g. "Brief me on the deployment status" or "I need your decision on the database schema"'),
|
|
390
|
+
persona_id: z.string().optional().describe('Persona ID to use (defaults to user\'s default persona)'),
|
|
391
|
+
wait_for_result: z.boolean().default(true).describe('Wait for the call to complete and return transcript/summary (may take several minutes)'),
|
|
392
|
+
}, async ({ brief, persona_id, wait_for_result }) => ({
|
|
393
|
+
content: await handleCallMe({ brief, persona_id, wait_for_result }),
|
|
394
|
+
}));
|
|
395
|
+
server.tool('make_call', 'Call a contact on the user\'s behalf. The AI calls the contact directly, knowing the purpose and who it\'s speaking with. Use when the user says "call [contact] about [topic]". Set wait_for_result=true to block until the call completes and return the transcript and summary.', {
|
|
396
|
+
contact_id: z.string().optional().describe('Contact ID to call (use if known)'),
|
|
397
|
+
contact_name: z.string().optional().describe('Contact name to search for (fuzzy match)'),
|
|
398
|
+
brief: z.string().describe('Purpose/brief for the call — what should the AI assistant discuss'),
|
|
399
|
+
persona_id: z.string().optional().describe('Persona ID to use (defaults to user\'s default persona)'),
|
|
400
|
+
wait_for_result: z.boolean().default(true).describe('Wait for the call to complete and return transcript/summary (may take several minutes)'),
|
|
401
|
+
}, async ({ contact_id, contact_name, brief, persona_id, wait_for_result }) => ({
|
|
402
|
+
content: await handleMakeCall({ contact_id, contact_name, brief, persona_id, wait_for_result }),
|
|
403
|
+
}));
|
|
404
|
+
server.tool('schedule_call', 'Schedule a phone call for a future time. Resolves contacts by ID or name.', {
|
|
405
|
+
contact_id: z.string().optional().describe('Contact ID to call'),
|
|
406
|
+
contact_name: z.string().optional().describe('Contact name to search for'),
|
|
407
|
+
brief: z.string().describe('Purpose/brief for the call'),
|
|
408
|
+
scheduled_at: z.string().describe('When to make the call (ISO 8601 datetime)'),
|
|
409
|
+
persona_id: z.string().optional().describe('Persona ID to use'),
|
|
410
|
+
}, async ({ contact_id, contact_name, brief, scheduled_at, persona_id }) => ({
|
|
411
|
+
content: await handleScheduleCall({ contact_id, contact_name, brief, scheduled_at, persona_id }),
|
|
412
|
+
}));
|
|
413
|
+
server.tool('cancel_call', 'Cancel an active or scheduled call by its ID.', {
|
|
414
|
+
call_id: z.string().describe('The call ID or scheduled call ID to cancel'),
|
|
415
|
+
}, async ({ call_id }) => ({
|
|
416
|
+
content: await handleCancelCall({ call_id }),
|
|
417
|
+
}));
|
|
418
|
+
server.tool('list_calls', 'List recent calls, optionally filtered by status.', {
|
|
419
|
+
limit: z.number().optional().describe('Max results (default 10)'),
|
|
420
|
+
status: z.string().optional().describe('Filter by status (e.g., "completed", "in-progress", "failed")'),
|
|
421
|
+
}, async ({ limit, status }) => ({
|
|
422
|
+
content: await handleListCalls({ limit, status }),
|
|
423
|
+
}));
|
|
424
|
+
server.tool('get_call', 'Get full details of a specific call, including transcript and summary.', {
|
|
425
|
+
call_id: z.string().describe('The call ID to retrieve'),
|
|
426
|
+
}, async ({ call_id }) => ({
|
|
427
|
+
content: await handleGetCall({ call_id }),
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
export declare function handleListContacts(params: {
|
|
3
|
+
search?: string;
|
|
4
|
+
limit?: number;
|
|
5
|
+
offset?: number;
|
|
6
|
+
}): Promise<{
|
|
7
|
+
type: 'text';
|
|
8
|
+
text: string;
|
|
9
|
+
}[]>;
|
|
10
|
+
export declare function handleCreateContact(params: {
|
|
11
|
+
full_name: string;
|
|
12
|
+
phone_number?: string;
|
|
13
|
+
email?: string;
|
|
14
|
+
notes?: string;
|
|
15
|
+
}): Promise<{
|
|
16
|
+
type: 'text';
|
|
17
|
+
text: string;
|
|
18
|
+
}[]>;
|
|
19
|
+
export declare function registerContactTools(server: McpServer): void;
|
|
20
|
+
//# sourceMappingURL=contacts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contacts.d.ts","sourceRoot":"","sources":["../../src/tools/contacts.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAKzE,wBAAsB,kBAAkB,CAAC,MAAM,EAAE;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAAC,CAwC5C;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAAC,CAsB5C;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA2B5D"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getAuthenticatedClient, refreshSessionIfNeeded, getUserId } from '../lib/supabase.js';
|
|
3
|
+
import { escapeIlikePattern } from '../lib/utils.js';
|
|
4
|
+
export async function handleListContacts(params) {
|
|
5
|
+
await refreshSessionIfNeeded();
|
|
6
|
+
const supabase = getAuthenticatedClient();
|
|
7
|
+
const userId = getUserId();
|
|
8
|
+
const limit = params.limit ?? 20;
|
|
9
|
+
const offset = params.offset ?? 0;
|
|
10
|
+
let query = supabase
|
|
11
|
+
.from('contacts')
|
|
12
|
+
.select('id, full_name, phone_number, email, notes, last_contacted_at, created_at')
|
|
13
|
+
.eq('user_id', userId)
|
|
14
|
+
.order('full_name', { ascending: true })
|
|
15
|
+
.range(offset, offset + limit - 1);
|
|
16
|
+
if (params.search) {
|
|
17
|
+
query = query.ilike('full_name', `%${escapeIlikePattern(params.search)}%`);
|
|
18
|
+
}
|
|
19
|
+
const { data, error } = await query;
|
|
20
|
+
if (error) {
|
|
21
|
+
return [{ type: 'text', text: `Error listing contacts: ${error.message}` }];
|
|
22
|
+
}
|
|
23
|
+
if (!data || data.length === 0) {
|
|
24
|
+
return [{ type: 'text', text: params.search ? `No contacts found matching "${params.search}".` : 'No contacts found.' }];
|
|
25
|
+
}
|
|
26
|
+
const lines = data.map((c) => {
|
|
27
|
+
const parts = [`**${c.full_name}**`];
|
|
28
|
+
if (c.phone_number)
|
|
29
|
+
parts.push(`Phone: ${c.phone_number}`);
|
|
30
|
+
if (c.email)
|
|
31
|
+
parts.push(`Email: ${c.email}`);
|
|
32
|
+
if (c.notes)
|
|
33
|
+
parts.push(`Notes: ${c.notes}`);
|
|
34
|
+
if (c.last_contacted_at)
|
|
35
|
+
parts.push(`Last contacted: ${c.last_contacted_at}`);
|
|
36
|
+
parts.push(`ID: ${c.id}`);
|
|
37
|
+
return parts.join(' | ');
|
|
38
|
+
});
|
|
39
|
+
return [{ type: 'text', text: `Found ${data.length} contact(s):\n\n${lines.join('\n')}` }];
|
|
40
|
+
}
|
|
41
|
+
export async function handleCreateContact(params) {
|
|
42
|
+
await refreshSessionIfNeeded();
|
|
43
|
+
const supabase = getAuthenticatedClient();
|
|
44
|
+
const userId = getUserId();
|
|
45
|
+
const { data, error } = await supabase
|
|
46
|
+
.from('contacts')
|
|
47
|
+
.insert({
|
|
48
|
+
full_name: params.full_name,
|
|
49
|
+
phone_number: params.phone_number ?? null,
|
|
50
|
+
email: params.email ?? null,
|
|
51
|
+
notes: params.notes ?? null,
|
|
52
|
+
user_id: userId,
|
|
53
|
+
})
|
|
54
|
+
.select()
|
|
55
|
+
.single();
|
|
56
|
+
if (error) {
|
|
57
|
+
return [{ type: 'text', text: `Error creating contact: ${error.message}` }];
|
|
58
|
+
}
|
|
59
|
+
return [{ type: 'text', text: `Contact created:\n\n**${data.full_name}**\nID: ${data.id}\nPhone: ${data.phone_number ?? 'N/A'}\nEmail: ${data.email ?? 'N/A'}` }];
|
|
60
|
+
}
|
|
61
|
+
export function registerContactTools(server) {
|
|
62
|
+
server.tool('list_contacts', 'List contacts, optionally filtered by name search. Returns name, phone, email, notes.', {
|
|
63
|
+
search: z.string().optional().describe('Filter contacts by name (fuzzy match)'),
|
|
64
|
+
limit: z.number().optional().describe('Max results to return (default 20)'),
|
|
65
|
+
offset: z.number().optional().describe('Offset for pagination (default 0)'),
|
|
66
|
+
}, async ({ search, limit, offset }) => ({
|
|
67
|
+
content: await handleListContacts({ search, limit, offset }),
|
|
68
|
+
}));
|
|
69
|
+
server.tool('create_contact', 'Create a new contact with name, phone number, email, and/or notes.', {
|
|
70
|
+
full_name: z.string().describe('Full name of the contact'),
|
|
71
|
+
phone_number: z.string().optional().describe('Phone number (E.164 format preferred)'),
|
|
72
|
+
email: z.string().optional().describe('Email address'),
|
|
73
|
+
notes: z.string().optional().describe('Notes about the contact'),
|
|
74
|
+
}, async ({ full_name, phone_number, email, notes }) => ({
|
|
75
|
+
content: await handleCreateContact({ full_name, phone_number, email, notes }),
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
export declare function handleListTasks(params: {
|
|
3
|
+
status?: 'pending' | 'completed' | 'cancelled';
|
|
4
|
+
limit?: number;
|
|
5
|
+
}): Promise<{
|
|
6
|
+
type: 'text';
|
|
7
|
+
text: string;
|
|
8
|
+
}[]>;
|
|
9
|
+
export declare function handleCreateTask(params: {
|
|
10
|
+
title: string;
|
|
11
|
+
notes?: string;
|
|
12
|
+
due_at?: string;
|
|
13
|
+
priority?: 'low' | 'medium' | 'high';
|
|
14
|
+
reminder_method?: string;
|
|
15
|
+
reminder_at?: string;
|
|
16
|
+
}): Promise<{
|
|
17
|
+
type: 'text';
|
|
18
|
+
text: string;
|
|
19
|
+
}[]>;
|
|
20
|
+
export declare function handleUpdateTask(params: {
|
|
21
|
+
task_id: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
notes?: string;
|
|
24
|
+
due_at?: string;
|
|
25
|
+
priority?: 'low' | 'medium' | 'high';
|
|
26
|
+
status?: 'pending' | 'completed' | 'cancelled';
|
|
27
|
+
}): Promise<{
|
|
28
|
+
type: 'text';
|
|
29
|
+
text: string;
|
|
30
|
+
}[]>;
|
|
31
|
+
export declare function registerTaskTools(server: McpServer): void;
|
|
32
|
+
//# sourceMappingURL=tasks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tasks.d.ts","sourceRoot":"","sources":["../../src/tools/tasks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAIzE,wBAAsB,eAAe,CAAC,MAAM,EAAE;IAC5C,MAAM,CAAC,EAAE,SAAS,GAAG,WAAW,GAAG,WAAW,CAAC;IAC/C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAAC,CAuC5C;AAED,wBAAsB,gBAAgB,CAAC,MAAM,EAAE;IAC7C,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAAC,CA0B5C;AAED,wBAAsB,gBAAgB,CAAC,MAAM,EAAE;IAC7C,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrC,MAAM,CAAC,EAAE,SAAS,GAAG,WAAW,GAAG,WAAW,CAAC;CAChD,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAAC,CA8B5C;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA4CzD"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getAuthenticatedClient, refreshSessionIfNeeded, getUserId } from '../lib/supabase.js';
|
|
3
|
+
export async function handleListTasks(params) {
|
|
4
|
+
await refreshSessionIfNeeded();
|
|
5
|
+
const supabase = getAuthenticatedClient();
|
|
6
|
+
const userId = getUserId();
|
|
7
|
+
const limit = params.limit ?? 20;
|
|
8
|
+
let query = supabase
|
|
9
|
+
.from('tasks')
|
|
10
|
+
.select('id, title, notes, status, priority, due_at, reminder_at, reminder_method, completed_at, source, created_at')
|
|
11
|
+
.eq('user_id', userId)
|
|
12
|
+
.order('created_at', { ascending: false })
|
|
13
|
+
.limit(limit);
|
|
14
|
+
if (params.status) {
|
|
15
|
+
query = query.eq('status', params.status);
|
|
16
|
+
}
|
|
17
|
+
const { data, error } = await query;
|
|
18
|
+
if (error) {
|
|
19
|
+
return [{ type: 'text', text: `Error listing tasks: ${error.message}` }];
|
|
20
|
+
}
|
|
21
|
+
if (!data || data.length === 0) {
|
|
22
|
+
return [{ type: 'text', text: params.status ? `No ${params.status} tasks found.` : 'No tasks found.' }];
|
|
23
|
+
}
|
|
24
|
+
const lines = data.map((t) => {
|
|
25
|
+
const parts = [`**${t.title}**`, `Status: ${t.status}`];
|
|
26
|
+
if (t.priority)
|
|
27
|
+
parts.push(`Priority: ${t.priority}`);
|
|
28
|
+
if (t.due_at)
|
|
29
|
+
parts.push(`Due: ${t.due_at}`);
|
|
30
|
+
if (t.notes)
|
|
31
|
+
parts.push(`Notes: ${t.notes}`);
|
|
32
|
+
if (t.reminder_at)
|
|
33
|
+
parts.push(`Reminder: ${t.reminder_at} (${t.reminder_method ?? 'default'})`);
|
|
34
|
+
parts.push(`ID: ${t.id}`);
|
|
35
|
+
return parts.join(' | ');
|
|
36
|
+
});
|
|
37
|
+
return [{ type: 'text', text: `Found ${data.length} task(s):\n\n${lines.join('\n')}` }];
|
|
38
|
+
}
|
|
39
|
+
export async function handleCreateTask(params) {
|
|
40
|
+
await refreshSessionIfNeeded();
|
|
41
|
+
const supabase = getAuthenticatedClient();
|
|
42
|
+
const userId = getUserId();
|
|
43
|
+
const { data, error } = await supabase
|
|
44
|
+
.from('tasks')
|
|
45
|
+
.insert({
|
|
46
|
+
title: params.title,
|
|
47
|
+
notes: params.notes ?? null,
|
|
48
|
+
due_at: params.due_at ?? null,
|
|
49
|
+
priority: params.priority ?? null,
|
|
50
|
+
reminder_method: params.reminder_method ?? null,
|
|
51
|
+
reminder_at: params.reminder_at ?? null,
|
|
52
|
+
source: 'mcp',
|
|
53
|
+
status: 'pending',
|
|
54
|
+
user_id: userId,
|
|
55
|
+
})
|
|
56
|
+
.select()
|
|
57
|
+
.single();
|
|
58
|
+
if (error) {
|
|
59
|
+
return [{ type: 'text', text: `Error creating task: ${error.message}` }];
|
|
60
|
+
}
|
|
61
|
+
return [{ type: 'text', text: `Task created:\n\n**${data.title}**\nStatus: ${data.status}\nPriority: ${data.priority ?? 'none'}\nDue: ${data.due_at ?? 'N/A'}\nID: ${data.id}` }];
|
|
62
|
+
}
|
|
63
|
+
export async function handleUpdateTask(params) {
|
|
64
|
+
await refreshSessionIfNeeded();
|
|
65
|
+
const supabase = getAuthenticatedClient();
|
|
66
|
+
const userId = getUserId();
|
|
67
|
+
const updates = {};
|
|
68
|
+
if (params.title !== undefined)
|
|
69
|
+
updates.title = params.title;
|
|
70
|
+
if (params.notes !== undefined)
|
|
71
|
+
updates.notes = params.notes;
|
|
72
|
+
if (params.due_at !== undefined)
|
|
73
|
+
updates.due_at = params.due_at;
|
|
74
|
+
if (params.priority !== undefined)
|
|
75
|
+
updates.priority = params.priority;
|
|
76
|
+
if (params.status !== undefined) {
|
|
77
|
+
updates.status = params.status;
|
|
78
|
+
if (params.status === 'completed') {
|
|
79
|
+
updates.completed_at = new Date().toISOString();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const { data, error } = await supabase
|
|
83
|
+
.from('tasks')
|
|
84
|
+
.update(updates)
|
|
85
|
+
.eq('id', params.task_id)
|
|
86
|
+
.eq('user_id', userId)
|
|
87
|
+
.select()
|
|
88
|
+
.single();
|
|
89
|
+
if (error) {
|
|
90
|
+
return [{ type: 'text', text: `Error updating task: ${error.message}` }];
|
|
91
|
+
}
|
|
92
|
+
return [{ type: 'text', text: `Task updated:\n\n**${data.title}**\nStatus: ${data.status}\nPriority: ${data.priority ?? 'none'}\nDue: ${data.due_at ?? 'N/A'}\nID: ${data.id}` }];
|
|
93
|
+
}
|
|
94
|
+
export function registerTaskTools(server) {
|
|
95
|
+
server.tool('list_tasks', 'List tasks, optionally filtered by status. Returns title, status, priority, due date.', {
|
|
96
|
+
status: z.enum(['pending', 'completed', 'cancelled']).optional().describe('Filter by task status'),
|
|
97
|
+
limit: z.number().optional().describe('Max results to return (default 20)'),
|
|
98
|
+
}, async ({ status, limit }) => ({
|
|
99
|
+
content: await handleListTasks({ status, limit }),
|
|
100
|
+
}));
|
|
101
|
+
server.tool('create_task', 'Create a new task with title, notes, due date, priority, and optional reminder.', {
|
|
102
|
+
title: z.string().describe('Task title'),
|
|
103
|
+
notes: z.string().optional().describe('Additional notes'),
|
|
104
|
+
due_at: z.string().optional().describe('Due date in ISO 8601 format'),
|
|
105
|
+
priority: z.enum(['low', 'medium', 'high']).optional().describe('Task priority'),
|
|
106
|
+
reminder_method: z.string().optional().describe('Reminder method (e.g., "call", "sms")'),
|
|
107
|
+
reminder_at: z.string().optional().describe('Reminder time in ISO 8601 format'),
|
|
108
|
+
}, async ({ title, notes, due_at, priority, reminder_method, reminder_at }) => ({
|
|
109
|
+
content: await handleCreateTask({ title, notes, due_at, priority, reminder_method, reminder_at }),
|
|
110
|
+
}));
|
|
111
|
+
server.tool('update_task', 'Update an existing task. Can change title, notes, due date, priority, or status. Setting status to "completed" automatically records the completion time.', {
|
|
112
|
+
task_id: z.string().describe('The task ID to update'),
|
|
113
|
+
title: z.string().optional().describe('New title'),
|
|
114
|
+
notes: z.string().optional().describe('New notes'),
|
|
115
|
+
due_at: z.string().optional().describe('New due date in ISO 8601 format'),
|
|
116
|
+
priority: z.enum(['low', 'medium', 'high']).optional().describe('New priority'),
|
|
117
|
+
status: z.enum(['pending', 'completed', 'cancelled']).optional().describe('New status'),
|
|
118
|
+
}, async ({ task_id, title, notes, due_at, priority, status }) => ({
|
|
119
|
+
content: await handleUpdateTask({ task_id, title, notes, due_at, priority, status }),
|
|
120
|
+
}));
|
|
121
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@telitask/mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TeliTask MCP server — manage contacts, tasks, and calls from AI assistants",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/Telitask/Telitask",
|
|
10
|
+
"directory": "packages/mcp-server"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["mcp", "telitask", "ai-assistant", "voice", "calls", "contacts", "tasks"],
|
|
13
|
+
"bin": {
|
|
14
|
+
"telitask-mcp": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"files": ["dist"],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"prepublishOnly": "npm run build",
|
|
25
|
+
"dev": "tsx watch src/index.ts",
|
|
26
|
+
"lint": "eslint src/",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest watch",
|
|
30
|
+
"clean": "rm -rf dist"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
34
|
+
"@supabase/supabase-js": "^2.49.4",
|
|
35
|
+
"open": "^10.1.0",
|
|
36
|
+
"zod": "^3.24.2"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@telitask/shared": "workspace:*",
|
|
40
|
+
"@types/node": "^22.10.5",
|
|
41
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
42
|
+
"tsx": "^4.19.0",
|
|
43
|
+
"typescript": "^5.7.3",
|
|
44
|
+
"vitest": "^3.0.0"
|
|
45
|
+
}
|
|
46
|
+
}
|