@projectsolo/solo-mission-mcp 0.19.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/.env.example +2 -0
- package/.github/workflows/release.yml +52 -0
- package/DEVELOPER_README.md +120 -0
- package/README.md +207 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1040 -0
- package/package.json +32 -0
- package/src/api/client.ts +71 -0
- package/src/config.ts +10 -0
- package/src/index.ts +89 -0
- package/src/realtime/missionPoller.ts +104 -0
- package/src/realtime/poller.ts +128 -0
- package/src/tools/agent.ts +34 -0
- package/src/tools/conversations.ts +158 -0
- package/src/tools/humans.ts +58 -0
- package/src/tools/missions.ts +293 -0
- package/src/tools/realtime.ts +163 -0
- package/src/tools/tracks.ts +130 -0
- package/tsconfig.json +16 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@projectsolo/solo-mission-mcp",
|
|
3
|
+
"version": "0.19.0",
|
|
4
|
+
"description": "MCP server for Solo Mission Platform — lets AI agents create missions, browse humans, and chat.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"solo-mission-mcp": "dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
15
|
+
"dev": "tsx watch src/index.ts",
|
|
16
|
+
"start": "node dist/index.js",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
21
|
+
"dotenv": "^16.4.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.0.0",
|
|
25
|
+
"tsup": "^8.3.0",
|
|
26
|
+
"tsx": "^4.19.0",
|
|
27
|
+
"typescript": "^5.7.0"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { config } from '../config.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_HEADERS: Record<string, string> = {
|
|
4
|
+
'Content-Type': 'application/json',
|
|
5
|
+
'X-Agent-Key': config.agentKey,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
class ApiResponseError extends Error {
|
|
9
|
+
status: number;
|
|
10
|
+
data: unknown;
|
|
11
|
+
constructor(status: number, data: unknown) {
|
|
12
|
+
const msg = (data as any)?.message || (data as any)?.error || `Request failed with status ${status}`;
|
|
13
|
+
super(status === 429 ? 'Rate limit exceeded. Please slow down and retry after a moment.' : msg);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.data = data;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function parseErrorResponse(response: Response): Promise<never> {
|
|
20
|
+
const data = await response.json().catch(() => ({}));
|
|
21
|
+
throw new ApiResponseError(response.status, data);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Unwrap fetch response and propagate API errors cleanly */
|
|
25
|
+
export async function apiGet<T>(path: string, params?: Record<string, any>): Promise<T> {
|
|
26
|
+
const url = new URL(`${config.apiUrl}${path}`);
|
|
27
|
+
if (params) {
|
|
28
|
+
for (const [key, value] of Object.entries(params)) {
|
|
29
|
+
if (value != null) url.searchParams.set(key, String(value));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const response = await fetch(url.toString(), {
|
|
33
|
+
headers: DEFAULT_HEADERS,
|
|
34
|
+
signal: AbortSignal.timeout(30_000),
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) return parseErrorResponse(response);
|
|
37
|
+
return response.json() as Promise<T>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
|
|
41
|
+
const response = await fetch(`${config.apiUrl}${path}`, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: DEFAULT_HEADERS,
|
|
44
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
45
|
+
signal: AbortSignal.timeout(30_000),
|
|
46
|
+
});
|
|
47
|
+
if (!response.ok) return parseErrorResponse(response);
|
|
48
|
+
return response.json() as Promise<T>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function apiDelete<T>(path: string): Promise<T> {
|
|
52
|
+
const response = await fetch(`${config.apiUrl}${path}`, {
|
|
53
|
+
method: 'DELETE',
|
|
54
|
+
headers: DEFAULT_HEADERS,
|
|
55
|
+
signal: AbortSignal.timeout(30_000),
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) return parseErrorResponse(response);
|
|
58
|
+
return response.json() as Promise<T>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** POST without X-Agent-Key — for public endpoints like /agent/register */
|
|
62
|
+
export async function publicApiPost<T>(path: string, body?: unknown): Promise<T> {
|
|
63
|
+
const response = await fetch(`${config.apiUrl}${path}`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'Content-Type': 'application/json' },
|
|
66
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
67
|
+
signal: AbortSignal.timeout(30_000),
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) return parseErrorResponse(response);
|
|
70
|
+
return response.json() as Promise<T>;
|
|
71
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
|
|
3
|
+
export const config = {
|
|
4
|
+
agentKey: process.env.SOLO_AGENT_KEY ?? '',
|
|
5
|
+
apiUrl: process.env.SOLO_MISSION_API_URL ?? 'https://api.mission.projectsolo.xyz',
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
if (!config.agentKey) {
|
|
9
|
+
console.warn('Warning: SOLO_AGENT_KEY is not set. Only register_agent will work until a key is configured.');
|
|
10
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Solo Mission MCP Server
|
|
4
|
+
* Exposes 15 tools for AI agents to interact with the Solo Mission Platform.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
8
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
|
+
import {
|
|
10
|
+
CallToolRequestSchema,
|
|
11
|
+
ListToolsRequestSchema,
|
|
12
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
13
|
+
|
|
14
|
+
// Tool definitions
|
|
15
|
+
import { missionTools, handleMissionTool } from './tools/missions.js';
|
|
16
|
+
import { humanTools, handleHumanTool } from './tools/humans.js';
|
|
17
|
+
import { conversationTools, handleConversationTool } from './tools/conversations.js';
|
|
18
|
+
import { realtimeTools, handleRealtimeTool } from './tools/realtime.js';
|
|
19
|
+
import { agentTools, handleAgentTool } from './tools/agent.js';
|
|
20
|
+
import { trackTools, handleTrackTool } from './tools/tracks.js';
|
|
21
|
+
|
|
22
|
+
const ALL_TOOLS = [...agentTools, ...missionTools, ...humanTools, ...conversationTools, ...realtimeTools, ...trackTools];
|
|
23
|
+
|
|
24
|
+
const AGENT_TOOL_NAMES = new Set(agentTools.map((t) => t.name));
|
|
25
|
+
const MISSION_TOOL_NAMES = new Set(missionTools.map((t) => t.name));
|
|
26
|
+
const HUMAN_TOOL_NAMES = new Set(humanTools.map((t) => t.name));
|
|
27
|
+
const CONVERSATION_TOOL_NAMES = new Set(conversationTools.map((t) => t.name));
|
|
28
|
+
const REALTIME_TOOL_NAMES = new Set(realtimeTools.map((t) => t.name));
|
|
29
|
+
const TRACK_TOOL_NAMES = new Set(trackTools.map((t) => t.name));
|
|
30
|
+
|
|
31
|
+
const server = new Server(
|
|
32
|
+
{ name: 'solo-mission-mcp', version: '0.1.0' },
|
|
33
|
+
{ capabilities: { tools: {} } }
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
37
|
+
tools: ALL_TOOLS,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
41
|
+
const { name, arguments: args = {} } = request.params;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
let result: unknown;
|
|
45
|
+
|
|
46
|
+
if (AGENT_TOOL_NAMES.has(name)) {
|
|
47
|
+
result = await handleAgentTool(name, args as Record<string, any>);
|
|
48
|
+
} else if (MISSION_TOOL_NAMES.has(name)) {
|
|
49
|
+
result = await handleMissionTool(name, args as Record<string, any>);
|
|
50
|
+
} else if (HUMAN_TOOL_NAMES.has(name)) {
|
|
51
|
+
result = await handleHumanTool(name, args as Record<string, any>);
|
|
52
|
+
} else if (CONVERSATION_TOOL_NAMES.has(name)) {
|
|
53
|
+
result = await handleConversationTool(name, args as Record<string, any>);
|
|
54
|
+
} else if (REALTIME_TOOL_NAMES.has(name)) {
|
|
55
|
+
result = await handleRealtimeTool(name, args as Record<string, any>);
|
|
56
|
+
} else if (TRACK_TOOL_NAMES.has(name)) {
|
|
57
|
+
result = await handleTrackTool(name, args as Record<string, any>);
|
|
58
|
+
} else {
|
|
59
|
+
return {
|
|
60
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
61
|
+
isError: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
67
|
+
};
|
|
68
|
+
} catch (error: any) {
|
|
69
|
+
const message = error?.response?.data
|
|
70
|
+
? JSON.stringify(error.response.data)
|
|
71
|
+
: (error?.message ?? String(error));
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
75
|
+
isError: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
async function main() {
|
|
81
|
+
const transport = new StdioServerTransport();
|
|
82
|
+
await server.connect(transport);
|
|
83
|
+
// Server runs until process exits
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
main().catch((err) => {
|
|
87
|
+
console.error('Failed to start solo-mission-mcp:', err);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { apiGet } from '../api/client.js';
|
|
2
|
+
|
|
3
|
+
export interface MissionUpdate {
|
|
4
|
+
mission_id: string;
|
|
5
|
+
type: 'new_participant';
|
|
6
|
+
participant: {
|
|
7
|
+
uid: string;
|
|
8
|
+
user_id: string;
|
|
9
|
+
joined_at: string;
|
|
10
|
+
conversation_id?: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface MissionWatchState {
|
|
15
|
+
intervalId: ReturnType<typeof setInterval>;
|
|
16
|
+
knownParticipantUids: Set<string>;
|
|
17
|
+
queue: MissionUpdate[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface MissionResponse {
|
|
21
|
+
mission: { mission_id: string };
|
|
22
|
+
participants: Array<{
|
|
23
|
+
uid: string;
|
|
24
|
+
user_id: string;
|
|
25
|
+
joined_at: string;
|
|
26
|
+
conversation_id?: string;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** In-process store: missionId → watch state */
|
|
31
|
+
const missionWatches = new Map<string, MissionWatchState>();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Start polling a mission for new participants.
|
|
35
|
+
* Already-known participants at watch time are seeded into knownParticipantUids.
|
|
36
|
+
*/
|
|
37
|
+
export async function startMissionPolling(missionId: string, intervalMinutes = 10): Promise<void> {
|
|
38
|
+
if (missionWatches.has(missionId)) return;
|
|
39
|
+
|
|
40
|
+
// Seed known participants so we only surface new ones going forward
|
|
41
|
+
let knownParticipantUids = new Set<string>();
|
|
42
|
+
try {
|
|
43
|
+
const data = await apiGet<MissionResponse>(`/agent/missions/${missionId}`);
|
|
44
|
+
for (const p of data.participants ?? []) {
|
|
45
|
+
knownParticipantUids.add(p.uid);
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// If initial fetch fails, start with empty set so all participants are surfaced on first tick
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const intervalMs = intervalMinutes * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
const state: MissionWatchState = {
|
|
54
|
+
knownParticipantUids,
|
|
55
|
+
queue: [],
|
|
56
|
+
intervalId: setInterval(async () => {
|
|
57
|
+
try {
|
|
58
|
+
const data = await apiGet<MissionResponse>(`/agent/missions/${missionId}`);
|
|
59
|
+
for (const p of data.participants ?? []) {
|
|
60
|
+
if (!state.knownParticipantUids.has(p.uid)) {
|
|
61
|
+
state.knownParticipantUids.add(p.uid);
|
|
62
|
+
state.queue.push({
|
|
63
|
+
mission_id: missionId,
|
|
64
|
+
type: 'new_participant',
|
|
65
|
+
participant: {
|
|
66
|
+
uid: p.uid,
|
|
67
|
+
user_id: p.user_id,
|
|
68
|
+
joined_at: p.joined_at,
|
|
69
|
+
...(p.conversation_id ? { conversation_id: p.conversation_id } : {}),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Silently ignore transient errors; polling continues
|
|
76
|
+
}
|
|
77
|
+
}, intervalMs),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
missionWatches.set(missionId, state);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Return and clear all buffered mission updates. */
|
|
84
|
+
export function drainMissionQueue(missionId: string): MissionUpdate[] {
|
|
85
|
+
const state = missionWatches.get(missionId);
|
|
86
|
+
if (!state) return [];
|
|
87
|
+
const updates = [...state.queue];
|
|
88
|
+
state.queue = [];
|
|
89
|
+
return updates;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Stop polling a mission. */
|
|
93
|
+
export function stopMissionPolling(missionId: string): boolean {
|
|
94
|
+
const state = missionWatches.get(missionId);
|
|
95
|
+
if (!state) return false;
|
|
96
|
+
clearInterval(state.intervalId);
|
|
97
|
+
missionWatches.delete(missionId);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Return list of currently watched mission IDs. */
|
|
102
|
+
export function listWatchedMissions(): string[] {
|
|
103
|
+
return [...missionWatches.keys()];
|
|
104
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { apiGet } from '../api/client.js';
|
|
2
|
+
|
|
3
|
+
interface QueuedMessage {
|
|
4
|
+
conversation_id: string;
|
|
5
|
+
message_id: string;
|
|
6
|
+
sender_type: 'agent' | 'human';
|
|
7
|
+
sender_id: string;
|
|
8
|
+
content: string;
|
|
9
|
+
created_at: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface MessagesResponse {
|
|
13
|
+
messages: QueuedMessage[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface WatchState {
|
|
17
|
+
timeoutId: ReturnType<typeof setTimeout> | null;
|
|
18
|
+
fibStep: number;
|
|
19
|
+
lastSeen: string;
|
|
20
|
+
queue: QueuedMessage[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** In-process store: conversationId → watch state */
|
|
24
|
+
const watches = new Map<string, WatchState>();
|
|
25
|
+
|
|
26
|
+
const FIBONACCI_MS = [
|
|
27
|
+
1_000, 1_000, 2_000, 3_000, 5_000, 8_000, 13_000,
|
|
28
|
+
21_000, 34_000, 55_000, 89_000, 144_000, 233_000, 377_000, 600_000,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function getFibMs(step: number): number {
|
|
32
|
+
return FIBONACCI_MS[Math.min(step, FIBONACCI_MS.length - 1)];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function scheduleNext(conversationId: string, state: WatchState): void {
|
|
36
|
+
state.timeoutId = setTimeout(() => pollOnce(conversationId, state), getFibMs(state.fibStep));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function pollOnce(conversationId: string, state: WatchState): Promise<void> {
|
|
40
|
+
if (!watches.has(conversationId)) return;
|
|
41
|
+
try {
|
|
42
|
+
const data = await apiGet<MessagesResponse>(
|
|
43
|
+
`/agent/conversations/${conversationId}/messages`,
|
|
44
|
+
{ since: state.lastSeen }
|
|
45
|
+
);
|
|
46
|
+
const msgs = data.messages ?? [];
|
|
47
|
+
if (msgs.length > 0) {
|
|
48
|
+
state.queue.push(...msgs);
|
|
49
|
+
const latest = msgs[msgs.length - 1];
|
|
50
|
+
if (latest?.created_at) {
|
|
51
|
+
// The API returns created_at as a Firestore Timestamp object { _seconds, _nanoseconds }
|
|
52
|
+
// or sometimes as a string. Normalise to ISO string so it's safe to use as a query param.
|
|
53
|
+
const raw = latest.created_at as any;
|
|
54
|
+
if (typeof raw === 'string') {
|
|
55
|
+
state.lastSeen = raw;
|
|
56
|
+
} else if (raw?._seconds !== undefined) {
|
|
57
|
+
state.lastSeen = new Date(raw._seconds * 1000 + Math.floor((raw._nanoseconds ?? 0) / 1_000_000)).toISOString();
|
|
58
|
+
} else if (raw?.seconds !== undefined) {
|
|
59
|
+
state.lastSeen = new Date(raw.seconds * 1000).toISOString();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Human replied — reset to step 0 (1s)
|
|
63
|
+
if (msgs.some(m => m.sender_type === 'human')) {
|
|
64
|
+
state.fibStep = 0;
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
// No new messages — advance to next Fibonacci step
|
|
68
|
+
state.fibStep = Math.min(state.fibStep + 1, FIBONACCI_MS.length - 1);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Silently ignore transient errors; polling continues
|
|
72
|
+
}
|
|
73
|
+
if (watches.has(conversationId)) {
|
|
74
|
+
scheduleNext(conversationId, state);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Start polling a conversation for new messages.
|
|
80
|
+
* Uses Fibonacci delay schedule: starts at 1s, advances on each miss, resets to 1s on human reply, caps at 600s.
|
|
81
|
+
*/
|
|
82
|
+
export function startPolling(conversationId: string): void {
|
|
83
|
+
if (watches.has(conversationId)) return;
|
|
84
|
+
|
|
85
|
+
const state: WatchState = {
|
|
86
|
+
timeoutId: null,
|
|
87
|
+
fibStep: 0,
|
|
88
|
+
lastSeen: new Date().toISOString(),
|
|
89
|
+
queue: [],
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
watches.set(conversationId, state);
|
|
93
|
+
scheduleNext(conversationId, state);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Return and clear all buffered messages for a conversation.
|
|
98
|
+
*/
|
|
99
|
+
export function drainQueue(conversationId: string): QueuedMessage[] {
|
|
100
|
+
const state = watches.get(conversationId);
|
|
101
|
+
if (!state) return [];
|
|
102
|
+
const msgs = [...state.queue];
|
|
103
|
+
state.queue = [];
|
|
104
|
+
return msgs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Stop polling a conversation.
|
|
109
|
+
*/
|
|
110
|
+
export function stopPolling(conversationId: string): boolean {
|
|
111
|
+
const state = watches.get(conversationId);
|
|
112
|
+
if (!state) return false;
|
|
113
|
+
if (state.timeoutId !== null) clearTimeout(state.timeoutId);
|
|
114
|
+
watches.delete(conversationId);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Return list of currently watched conversation IDs */
|
|
119
|
+
export function listWatched(): string[] {
|
|
120
|
+
return [...watches.keys()];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Return current poll interval for a conversation (ms), or null if not watched */
|
|
124
|
+
export function getPollIntervalMs(conversationId: string): number | null {
|
|
125
|
+
const state = watches.get(conversationId);
|
|
126
|
+
if (!state) return null;
|
|
127
|
+
return getFibMs(state.fibStep);
|
|
128
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { publicApiPost } from '../api/client.js';
|
|
3
|
+
|
|
4
|
+
export const agentTools: Tool[] = [
|
|
5
|
+
{
|
|
6
|
+
name: 'register_agent',
|
|
7
|
+
description:
|
|
8
|
+
'Self-register a new agent on the Solo platform. No existing agent key required. ' +
|
|
9
|
+
'Returns agent_id and api_key — the key is shown only once, save it immediately. ' +
|
|
10
|
+
'Use this to bootstrap a fresh agent identity.',
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
name: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
description: 'Agent name (3–50 characters)',
|
|
17
|
+
minLength: 3,
|
|
18
|
+
maxLength: 50,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
required: ['name'],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export async function handleAgentTool(name: string, args: Record<string, any>): Promise<unknown> {
|
|
27
|
+
switch (name) {
|
|
28
|
+
case 'register_agent':
|
|
29
|
+
return publicApiPost('/agent/register', { name: args.name });
|
|
30
|
+
|
|
31
|
+
default:
|
|
32
|
+
throw new Error(`Unknown agent tool: ${name}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { apiGet, apiPost } from '../api/client.js';
|
|
3
|
+
import { stopPolling } from '../realtime/poller.js';
|
|
4
|
+
|
|
5
|
+
export const conversationTools: Tool[] = [
|
|
6
|
+
{
|
|
7
|
+
name: 'start_conversation',
|
|
8
|
+
description: 'Start a new conversation with a human. Idempotent — if a conversation already exists with this human (and mission), returns the existing one. If the existing conversation was archived, it will be reopened.',
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
human_uid: { type: 'string', description: "The human's Firebase uid — use the 'uid' field from browse_humans results, NOT 'user_id'. user_id is a mutable display name; uid is the stable internal identifier." },
|
|
13
|
+
initial_message: { type: 'string', description: 'First message to send' },
|
|
14
|
+
mission_id: { type: 'string', description: 'Optional: link conversation to a mission' },
|
|
15
|
+
},
|
|
16
|
+
required: ['human_uid', 'initial_message'],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'list_conversations',
|
|
21
|
+
description: 'List conversations. Use status filter to focus on active conversations. Pagination supported for agents with many conversations.',
|
|
22
|
+
inputSchema: {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
status: { type: 'string', enum: ['active', 'archived', 'closed'], description: 'Filter by status (default: all)' },
|
|
26
|
+
limit: { type: 'number', description: 'Max results (default 20, max 100)' },
|
|
27
|
+
page: { type: 'number', description: 'Page number (default 1)' },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'get_conversation_upload_url',
|
|
33
|
+
description: 'Get a signed upload URL to attach an image to a message. For agent uploads prefer upload_conversation_image instead. Otherwise: upload the file with PUT to the returned upload_url, then pass the returned storage_path in send_message attachment_paths.',
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
conversation_id: { type: 'string', description: 'Conversation ID' },
|
|
38
|
+
content_type: { type: 'string', description: 'MIME type of the file, e.g. image/jpeg, image/png, image/webp (default image/jpeg)' },
|
|
39
|
+
},
|
|
40
|
+
required: ['conversation_id'],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'upload_conversation_image',
|
|
45
|
+
description: 'Upload an image into a conversation and get its storage_path for use in send_message. Pass the image as base64-encoded data (e.g. from a user attachment or generated image). Returns storage_path to include in send_message attachment_paths.',
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: {
|
|
49
|
+
conversation_id: { type: 'string', description: 'Conversation ID' },
|
|
50
|
+
image_base64: { type: 'string', description: 'Base64-encoded image data (no data URL prefix)' },
|
|
51
|
+
content_type: { type: 'string', description: 'MIME type: image/jpeg, image/png, or image/webp (default image/jpeg)' },
|
|
52
|
+
},
|
|
53
|
+
required: ['conversation_id', 'image_base64'],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'send_message',
|
|
58
|
+
description: 'Send a message in an existing conversation. Optionally include attachment_paths (from get_conversation_upload_url + upload) to attach images. At least one of content or attachment_paths is required.',
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: 'object',
|
|
61
|
+
properties: {
|
|
62
|
+
conversation_id: { type: 'string', description: 'Conversation ID' },
|
|
63
|
+
content: { type: 'string', description: 'Message text (can be empty if attachment_paths provided)' },
|
|
64
|
+
attachment_paths: { type: 'array', items: { type: 'string' }, description: 'Storage paths from get_conversation_upload_url flow; max 4 per message' },
|
|
65
|
+
},
|
|
66
|
+
required: ['conversation_id'],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'get_messages',
|
|
71
|
+
description: 'Retrieve messages from a conversation. Use the `since` parameter to poll for new messages.',
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
conversation_id: { type: 'string', description: 'Conversation ID' },
|
|
76
|
+
since: { type: 'string', description: 'ISO 8601 timestamp — only return messages after this time' },
|
|
77
|
+
limit: { type: 'number', description: 'Max messages to return (default 50)' },
|
|
78
|
+
},
|
|
79
|
+
required: ['conversation_id'],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'close_conversation',
|
|
84
|
+
description: 'Manage conversation lifecycle. Use "archive" to shelve inactive conversations (reopenable). Use "close" to permanently end a conversation. Use "reopen" to resume an archived conversation. Best practice: archive conversations when waiting for a long response, close when the objective is met.',
|
|
85
|
+
inputSchema: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
properties: {
|
|
88
|
+
conversation_id: { type: 'string', description: 'Conversation ID' },
|
|
89
|
+
action: { type: 'string', enum: ['archive', 'close', 'reopen'], description: 'Action to perform' },
|
|
90
|
+
},
|
|
91
|
+
required: ['conversation_id', 'action'],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
export async function handleConversationTool(name: string, args: Record<string, any>): Promise<unknown> {
|
|
97
|
+
switch (name) {
|
|
98
|
+
case 'start_conversation':
|
|
99
|
+
return apiPost('/agent/conversations', {
|
|
100
|
+
human_uid: args.human_uid,
|
|
101
|
+
initial_message: args.initial_message,
|
|
102
|
+
...(args.mission_id ? { mission_id: args.mission_id } : {}),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
case 'list_conversations': {
|
|
106
|
+
const params: Record<string, any> = {};
|
|
107
|
+
if (args.status) params.status = args.status;
|
|
108
|
+
if (args.limit) params.limit = args.limit;
|
|
109
|
+
if (args.page) params.page = args.page;
|
|
110
|
+
return apiGet('/agent/conversations', params);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
case 'get_conversation_upload_url': {
|
|
114
|
+
const ct = args.content_type || 'image/jpeg';
|
|
115
|
+
const path = `/agent/conversations/${args.conversation_id}/upload-url?content_type=${encodeURIComponent(ct)}`;
|
|
116
|
+
return apiPost(path);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case 'upload_conversation_image': {
|
|
120
|
+
const ct = args.content_type || 'image/jpeg';
|
|
121
|
+
const path = `/agent/conversations/${args.conversation_id}/upload-url?content_type=${encodeURIComponent(ct)}`;
|
|
122
|
+
const { upload_url, storage_path } = (await apiPost(path)) as { upload_url: string; storage_path: string };
|
|
123
|
+
const body = Buffer.from(args.image_base64, 'base64');
|
|
124
|
+
const res = await fetch(upload_url, {
|
|
125
|
+
method: 'PUT',
|
|
126
|
+
body,
|
|
127
|
+
headers: { 'Content-Type': ct },
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok) throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
|
|
130
|
+
return { storage_path };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
case 'send_message': {
|
|
134
|
+
const body: Record<string, unknown> = { content: args.content ?? '' };
|
|
135
|
+
if (args.attachment_paths?.length) body.attachment_paths = args.attachment_paths;
|
|
136
|
+
return apiPost(`/agent/conversations/${args.conversation_id}/messages`, body);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case 'get_messages': {
|
|
140
|
+
const params: Record<string, any> = {};
|
|
141
|
+
if (args.since) params.since = args.since;
|
|
142
|
+
if (args.limit) params.limit = args.limit;
|
|
143
|
+
return apiGet(`/agent/conversations/${args.conversation_id}/messages`, params);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
case 'close_conversation': {
|
|
147
|
+
const result = await apiPost(`/agent/conversations/${args.conversation_id}/${args.action}`);
|
|
148
|
+
// Auto-unwatch if closing (no point polling a closed conversation)
|
|
149
|
+
if (args.action === 'close') {
|
|
150
|
+
stopPolling(args.conversation_id as string);
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
default:
|
|
156
|
+
throw new Error(`Unknown conversation tool: ${name}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { apiGet } from '../api/client.js';
|
|
3
|
+
|
|
4
|
+
export const humanTools: Tool[] = [
|
|
5
|
+
{
|
|
6
|
+
name: 'browse_humans',
|
|
7
|
+
description: 'Browse face-verified humans on the Solo platform. Filter by skills, location, languages, and more.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
skills: { type: 'string', description: 'Comma-separated skill keywords (e.g. "Software Development,Data Analysis")' },
|
|
12
|
+
location: { type: 'string', description: 'Filter by city or country' },
|
|
13
|
+
languages: { type: 'string', description: 'Comma-separated languages (e.g. "English,Spanish")' },
|
|
14
|
+
min_rating: { type: 'number', minimum: 0, maximum: 5, description: 'Minimum average rating (0–5)' },
|
|
15
|
+
max_hourly_rate: { type: 'number', description: 'Maximum hourly rate in USD' },
|
|
16
|
+
min_tier: { type: 'number', minimum: 1, description: 'Minimum contribution tier (1=Scout, 2=Curator, 3=Oracle)' },
|
|
17
|
+
sort_by: { type: 'string', enum: ['tier', 'avg_rating', 'completed_count'], description: 'Sort results by this field (descending)' },
|
|
18
|
+
limit: { type: 'number', description: 'Max results (default 20)' },
|
|
19
|
+
page: { type: 'number', description: 'Page number (default 1)' },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'get_human_profile',
|
|
25
|
+
description: 'Get the full mission profile of a specific human by their user_id.',
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
user_id: { type: 'string', description: 'The human\'s user_id (from browse_humans results)' },
|
|
30
|
+
},
|
|
31
|
+
required: ['user_id'],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export async function handleHumanTool(name: string, args: Record<string, any>): Promise<unknown> {
|
|
37
|
+
switch (name) {
|
|
38
|
+
case 'browse_humans': {
|
|
39
|
+
const params: Record<string, any> = {};
|
|
40
|
+
if (args.skills) params.skills = args.skills;
|
|
41
|
+
if (args.location) params.location = args.location;
|
|
42
|
+
if (args.languages) params.languages = args.languages;
|
|
43
|
+
if (args.min_rating !== undefined) params.min_rating = args.min_rating;
|
|
44
|
+
if (args.max_hourly_rate !== undefined) params.max_hourly_rate = args.max_hourly_rate;
|
|
45
|
+
if (args.min_tier !== undefined) params.min_tier = args.min_tier;
|
|
46
|
+
if (args.sort_by) params.sort_by = args.sort_by;
|
|
47
|
+
if (args.limit) params.limit = args.limit;
|
|
48
|
+
if (args.page) params.page = args.page;
|
|
49
|
+
return apiGet('/agent/humans', params);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
case 'get_human_profile':
|
|
53
|
+
return apiGet(`/agent/humans/${args.user_id}`);
|
|
54
|
+
|
|
55
|
+
default:
|
|
56
|
+
throw new Error(`Unknown human tool: ${name}`);
|
|
57
|
+
}
|
|
58
|
+
}
|