@flowrelay/mcp-server 0.4.1 → 0.6.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 +6 -2
- package/dist/api.d.ts +78 -34
- package/dist/api.js +45 -7
- package/dist/index.js +121 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ It connects to Flow Relay API v1 using an API key and supports both:
|
|
|
10
10
|
## Package
|
|
11
11
|
|
|
12
12
|
- Name: @flowrelay/mcp-server
|
|
13
|
-
- Version: 0.
|
|
13
|
+
- Version: 0.6.0
|
|
14
14
|
|
|
15
15
|
## What Is Included
|
|
16
16
|
|
|
@@ -20,7 +20,11 @@ The server currently exposes these tools:
|
|
|
20
20
|
- list_projects
|
|
21
21
|
- set_active_project
|
|
22
22
|
- list_handoffs
|
|
23
|
-
- generate_handoff
|
|
23
|
+
- generate_handoff (Note: Personal stream handoffs are synchronous. Project-scoped handoffs are processed asynchronously, and the server automatically polls until the job finishes.)
|
|
24
|
+
- generate_correlation_insight
|
|
25
|
+
- generate_onboarding_brief
|
|
26
|
+
- generate_architecture_insight
|
|
27
|
+
- list_insights
|
|
24
28
|
- list_integrations
|
|
25
29
|
- list_events
|
|
26
30
|
- discord_list_channels
|
package/dist/api.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Flow Relay API client
|
|
2
|
+
* Flow Relay API client – talks to flowrelay.it/api/v1/*
|
|
3
3
|
*/
|
|
4
4
|
export type AccountType = 'personal' | 'business';
|
|
5
5
|
export type AccessRole = 'owner' | 'admin' | 'member';
|
|
@@ -28,51 +28,95 @@ export interface TenantContext {
|
|
|
28
28
|
organizations: TenantOrganization[];
|
|
29
29
|
projects: TenantProject[];
|
|
30
30
|
}
|
|
31
|
+
export type AiJobStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
|
32
|
+
export interface AiJob {
|
|
33
|
+
id: string;
|
|
34
|
+
project_id: string;
|
|
35
|
+
kind: string;
|
|
36
|
+
status: AiJobStatus;
|
|
37
|
+
result_kind: 'handoff' | 'insight' | null;
|
|
38
|
+
result_id: string | null;
|
|
39
|
+
error: string | null;
|
|
40
|
+
error_code: string | null;
|
|
41
|
+
error_meta: Record<string, unknown>;
|
|
42
|
+
created_at: string;
|
|
43
|
+
updated_at: string;
|
|
44
|
+
}
|
|
45
|
+
export interface HandoffResult {
|
|
46
|
+
id: string;
|
|
47
|
+
user_id: string;
|
|
48
|
+
project_id: string | null;
|
|
49
|
+
project_name?: string | null;
|
|
50
|
+
scope_type?: 'personal' | 'project';
|
|
51
|
+
title: string;
|
|
52
|
+
summary: string;
|
|
53
|
+
status: string;
|
|
54
|
+
sources: string[];
|
|
55
|
+
key_changes?: string[];
|
|
56
|
+
decisions: string[];
|
|
57
|
+
open_questions: string[];
|
|
58
|
+
next_steps: string[];
|
|
59
|
+
created_at: string;
|
|
60
|
+
updated_at?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface InsightResult {
|
|
63
|
+
id: string;
|
|
64
|
+
project_id: string;
|
|
65
|
+
requested_by: string;
|
|
66
|
+
kind: 'onboarding_brief' | 'cross_source_correlation' | 'architecture_insight';
|
|
67
|
+
title: string;
|
|
68
|
+
summary: string;
|
|
69
|
+
data: Record<string, unknown>;
|
|
70
|
+
model_used: string;
|
|
71
|
+
token_usage: {
|
|
72
|
+
prompt_tokens: number;
|
|
73
|
+
completion_tokens: number;
|
|
74
|
+
total_tokens: number;
|
|
75
|
+
};
|
|
76
|
+
status: string;
|
|
77
|
+
related_event_ids: string[];
|
|
78
|
+
created_at: string;
|
|
79
|
+
updated_at?: string;
|
|
80
|
+
}
|
|
81
|
+
export type GenerateHandoffResponse = {
|
|
82
|
+
handoff: HandoffResult;
|
|
83
|
+
} | {
|
|
84
|
+
jobId: string;
|
|
85
|
+
status: AiJobStatus;
|
|
86
|
+
};
|
|
87
|
+
export type GenerateInsightResponse = {
|
|
88
|
+
jobId: string;
|
|
89
|
+
status: AiJobStatus;
|
|
90
|
+
};
|
|
31
91
|
export declare class FlowRelayAPI {
|
|
32
92
|
private baseUrl;
|
|
33
93
|
private apiKey;
|
|
34
94
|
constructor(apiKey: string, baseUrl?: string);
|
|
95
|
+
private requestRaw;
|
|
35
96
|
private request;
|
|
36
97
|
listProjects(): Promise<TenantContext>;
|
|
37
98
|
listHandoffs(status?: string, limit?: number, projectId?: string | null): Promise<{
|
|
38
|
-
handoffs:
|
|
39
|
-
id: string;
|
|
40
|
-
user_id: string;
|
|
41
|
-
project_id: string | null;
|
|
42
|
-
project_name?: string | null;
|
|
43
|
-
scope_type?: "personal" | "project";
|
|
44
|
-
title: string;
|
|
45
|
-
summary: string;
|
|
46
|
-
status: string;
|
|
47
|
-
sources: string[];
|
|
48
|
-
key_changes?: string[];
|
|
49
|
-
decisions: string[];
|
|
50
|
-
open_questions: string[];
|
|
51
|
-
next_steps: string[];
|
|
52
|
-
created_at: string;
|
|
53
|
-
updated_at: string;
|
|
54
|
-
}>;
|
|
99
|
+
handoffs: HandoffResult[];
|
|
55
100
|
}>;
|
|
56
101
|
generateHandoff(sources?: string[], filters?: Record<string, {
|
|
57
102
|
projects?: string[];
|
|
58
103
|
eventTypes?: string[];
|
|
59
|
-
}>, projectId?: string | null): Promise<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
created_at: string;
|
|
74
|
-
};
|
|
104
|
+
}>, projectId?: string | null): Promise<GenerateHandoffResponse>;
|
|
105
|
+
getJob(jobId: string): Promise<{
|
|
106
|
+
job: AiJob;
|
|
107
|
+
result: HandoffResult | InsightResult | null;
|
|
108
|
+
}>;
|
|
109
|
+
waitForJob(jobId: string, opts?: {
|
|
110
|
+
intervalMs?: number;
|
|
111
|
+
timeoutMs?: number;
|
|
112
|
+
}): Promise<{
|
|
113
|
+
job: AiJob;
|
|
114
|
+
result: HandoffResult | InsightResult | null;
|
|
115
|
+
}>;
|
|
116
|
+
listInsights(projectId: string, kind?: string, status?: string, limit?: number): Promise<{
|
|
117
|
+
insights: InsightResult[];
|
|
75
118
|
}>;
|
|
119
|
+
generateInsight(projectId: string, kind: 'correlation' | 'onboarding' | 'architecture', body?: Record<string, unknown>): Promise<GenerateInsightResponse>;
|
|
76
120
|
listIntegrations(projectId?: string | null): Promise<{
|
|
77
121
|
integrations: Array<{
|
|
78
122
|
source: string;
|
package/dist/api.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Flow Relay API client
|
|
2
|
+
* Flow Relay API client – talks to flowrelay.it/api/v1/*
|
|
3
3
|
*/
|
|
4
4
|
const DEFAULT_BASE_URL = 'https://www.flowrelay.it';
|
|
5
5
|
export class FlowRelayAPI {
|
|
@@ -9,7 +9,7 @@ export class FlowRelayAPI {
|
|
|
9
9
|
this.apiKey = apiKey;
|
|
10
10
|
this.baseUrl = baseUrl ?? DEFAULT_BASE_URL;
|
|
11
11
|
}
|
|
12
|
-
async
|
|
12
|
+
async requestRaw(path, options) {
|
|
13
13
|
const url = `${this.baseUrl}/api/v1${path}`;
|
|
14
14
|
const res = await fetch(url, {
|
|
15
15
|
...options,
|
|
@@ -23,10 +23,13 @@ export class FlowRelayAPI {
|
|
|
23
23
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
24
24
|
throw new Error(body.error ?? `API error ${res.status}`);
|
|
25
25
|
}
|
|
26
|
-
if (res.status === 204)
|
|
27
|
-
return {};
|
|
28
|
-
}
|
|
29
|
-
|
|
26
|
+
if (res.status === 204)
|
|
27
|
+
return { status: 204, body: {} };
|
|
28
|
+
return { status: res.status, body: (await res.json()) };
|
|
29
|
+
}
|
|
30
|
+
async request(path, options) {
|
|
31
|
+
const { body } = await this.requestRaw(path, options);
|
|
32
|
+
return body;
|
|
30
33
|
}
|
|
31
34
|
async listProjects() {
|
|
32
35
|
return this.request('/projects');
|
|
@@ -47,10 +50,45 @@ export class FlowRelayAPI {
|
|
|
47
50
|
body.filters = filters;
|
|
48
51
|
if (projectId)
|
|
49
52
|
body.project_id = projectId;
|
|
50
|
-
|
|
53
|
+
const { status, body: data } = await this.requestRaw('/handoffs', {
|
|
51
54
|
method: 'POST',
|
|
52
55
|
body: JSON.stringify(body),
|
|
53
56
|
});
|
|
57
|
+
// 202 → async job; 200 → inline handoff. Caller branches on the shape.
|
|
58
|
+
void status;
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
async getJob(jobId) {
|
|
62
|
+
return (await this.requestRaw(`/jobs/${jobId}`)).body;
|
|
63
|
+
}
|
|
64
|
+
async waitForJob(jobId, opts = {}) {
|
|
65
|
+
const intervalMs = opts.intervalMs ?? 2500;
|
|
66
|
+
const timeoutMs = opts.timeoutMs ?? 180_000; // 3 min hard cap
|
|
67
|
+
const deadline = Date.now() + timeoutMs;
|
|
68
|
+
while (true) {
|
|
69
|
+
const res = await this.getJob(jobId);
|
|
70
|
+
if (res.job.status === 'completed' || res.job.status === 'failed') {
|
|
71
|
+
return res;
|
|
72
|
+
}
|
|
73
|
+
if (Date.now() > deadline) {
|
|
74
|
+
throw new Error(`AI Job timed out after ${Math.round(timeoutMs / 1000)}s (job ${jobId} still ${res.job.status}).`);
|
|
75
|
+
}
|
|
76
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async listInsights(projectId, kind, status = 'active', limit = 20) {
|
|
80
|
+
const params = new URLSearchParams();
|
|
81
|
+
if (kind)
|
|
82
|
+
params.set('kind', kind);
|
|
83
|
+
params.set('status', status);
|
|
84
|
+
params.set('limit', String(limit));
|
|
85
|
+
return this.request(`/projects/${projectId}/insights?${params.toString()}`);
|
|
86
|
+
}
|
|
87
|
+
async generateInsight(projectId, kind, body) {
|
|
88
|
+
return this.request(`/projects/${projectId}/insights/${kind}`, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
body: JSON.stringify(body ?? {}),
|
|
91
|
+
});
|
|
54
92
|
}
|
|
55
93
|
async listIntegrations(projectId) {
|
|
56
94
|
const params = new URLSearchParams();
|
package/dist/index.js
CHANGED
|
@@ -50,7 +50,7 @@ const api = new FlowRelayAPI(apiKey, process.env.FLOWRELAY_BASE_URL);
|
|
|
50
50
|
let activeProjectId = normalizeProjectId(process.env.FLOWRELAY_PROJECT_ID);
|
|
51
51
|
const server = new McpServer({
|
|
52
52
|
name: 'flowrelay',
|
|
53
|
-
version: '0.
|
|
53
|
+
version: '0.6.0',
|
|
54
54
|
});
|
|
55
55
|
async function getTenantContext() {
|
|
56
56
|
const context = await api.listProjects();
|
|
@@ -189,11 +189,23 @@ server.tool('generate_handoff', 'Generate a new context handoff for personal sco
|
|
|
189
189
|
}, async ({ sources, filters, project_id }) => {
|
|
190
190
|
try {
|
|
191
191
|
const resolved = await resolveProject(project_id);
|
|
192
|
-
const
|
|
192
|
+
const res = await api.generateHandoff(sources, filters, resolved.projectId);
|
|
193
|
+
// Async path (project handoff): poll until the worker finishes.
|
|
194
|
+
let handoff;
|
|
195
|
+
if ('jobId' in res) {
|
|
196
|
+
const { job, result } = await api.waitForJob(res.jobId);
|
|
197
|
+
if (job.status === 'failed' || !result) {
|
|
198
|
+
const reason = job.error ?? 'unknown error';
|
|
199
|
+
return { content: [{ type: 'text', text: `Could not generate handoff: ${reason}` }] };
|
|
200
|
+
}
|
|
201
|
+
handoff = result;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
handoff = res.handoff;
|
|
205
|
+
}
|
|
193
206
|
let text = `# ${handoff.title}\n\n${handoff.summary}\n`;
|
|
194
|
-
if (handoff.project_name)
|
|
207
|
+
if (handoff.project_name)
|
|
195
208
|
text += `\n**Project:** ${handoff.project_name}\n`;
|
|
196
|
-
}
|
|
197
209
|
text += `\n**Sources:** ${handoff.sources.join(', ')}\n`;
|
|
198
210
|
if (handoff.key_changes?.length)
|
|
199
211
|
text += `\n**Key changes:**\n${handoff.key_changes.map((c) => `- ${c}`).join('\n')}\n`;
|
|
@@ -224,9 +236,9 @@ server.tool('list_integrations', 'List integrations in the current scope. In pro
|
|
|
224
236
|
if (i.scope === 'project') {
|
|
225
237
|
const status = i.connection_status ?? 'unknown';
|
|
226
238
|
const providers = i.providers_connected ?? 0;
|
|
227
|
-
return `- **${i.source}**${name}
|
|
239
|
+
return `- **${i.source}**${name} – status: ${status}, providers connected: ${providers}`;
|
|
228
240
|
}
|
|
229
|
-
return `- **${i.source}**${name}
|
|
241
|
+
return `- **${i.source}**${name} – connected ${new Date(i.connected_at).toLocaleDateString()}`;
|
|
230
242
|
}).join('\n');
|
|
231
243
|
return { content: [{ type: 'text', text: `**Connected integrations:**\n${text}` }] };
|
|
232
244
|
});
|
|
@@ -259,7 +271,7 @@ server.tool('discord_list_channels', 'List text channels in your connected Disco
|
|
|
259
271
|
return { content: [{ type: 'text', text: 'No text channels found in the connected Discord server.' }] };
|
|
260
272
|
}
|
|
261
273
|
const text = channels.map((ch) => {
|
|
262
|
-
const topic = ch.topic ? `
|
|
274
|
+
const topic = ch.topic ? ` – ${ch.topic}` : '';
|
|
263
275
|
return `- **#${ch.name}** (${ch.id})${topic}`;
|
|
264
276
|
}).join('\n');
|
|
265
277
|
return { content: [{ type: 'text', text: `**Discord channels:**\n${text}` }] };
|
|
@@ -281,6 +293,108 @@ server.tool('discord_send_message', 'Send a message to a Discord channel in your
|
|
|
281
293
|
return { content: [{ type: 'text', text: `Failed to send message: ${err.message}` }] };
|
|
282
294
|
}
|
|
283
295
|
});
|
|
296
|
+
// ── Tool: generate correlation insight ──────────────────────────────
|
|
297
|
+
server.tool('generate_correlation_insight', 'Generate a cross-source correlation AI insight for the specified project scope.', {
|
|
298
|
+
project_id: z.string().describe('The project ID scope to generate the correlation insight for.'),
|
|
299
|
+
sources: z.array(SourceEnum).optional().describe('Filter events to specific sources (e.g. "github", "slack")'),
|
|
300
|
+
lookback_hours: z.number().optional().describe('Number of hours of activity to analyze'),
|
|
301
|
+
max_events: z.number().optional().describe('Maximum number of events to process'),
|
|
302
|
+
}, async ({ project_id, sources, lookback_hours, max_events }) => {
|
|
303
|
+
try {
|
|
304
|
+
const res = await api.generateInsight(project_id, 'correlation', {
|
|
305
|
+
sources,
|
|
306
|
+
lookbackHours: lookback_hours,
|
|
307
|
+
maxEvents: max_events,
|
|
308
|
+
});
|
|
309
|
+
const { job, result } = await api.waitForJob(res.jobId);
|
|
310
|
+
if (job.status === 'failed' || !result) {
|
|
311
|
+
const reason = job.error ?? 'unknown error';
|
|
312
|
+
return { content: [{ type: 'text', text: `Could not generate correlation insight: ${reason}` }] };
|
|
313
|
+
}
|
|
314
|
+
const insight = result;
|
|
315
|
+
return { content: [{ type: 'text', text: `# ${insight.title}\n\n${insight.summary}` }] };
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
return { content: [{ type: 'text', text: `Failed: ${err.message}` }] };
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
// ── Tool: generate onboarding brief ──────────────────────────────────
|
|
322
|
+
server.tool('generate_onboarding_brief', 'Generate an onboarding brief AI insight for the specified project scope.', {
|
|
323
|
+
project_id: z.string().describe('The project ID scope to generate the onboarding brief for.'),
|
|
324
|
+
sources: z.array(SourceEnum).optional().describe('Filter events to specific sources'),
|
|
325
|
+
new_member_role: z.string().optional().describe('Expected role/focus of the new team member'),
|
|
326
|
+
focus_area: z.string().optional().describe('Specific repository or feature area they will work on'),
|
|
327
|
+
lookback_days: z.number().optional().describe('Number of days of history to review'),
|
|
328
|
+
max_events: z.number().optional().describe('Maximum events to process'),
|
|
329
|
+
}, async ({ project_id, sources, new_member_role, focus_area, lookback_days, max_events }) => {
|
|
330
|
+
try {
|
|
331
|
+
const res = await api.generateInsight(project_id, 'onboarding', {
|
|
332
|
+
sources,
|
|
333
|
+
newMemberRole: new_member_role,
|
|
334
|
+
focusArea: focus_area,
|
|
335
|
+
lookbackDays: lookback_days,
|
|
336
|
+
maxEvents: max_events,
|
|
337
|
+
});
|
|
338
|
+
const { job, result } = await api.waitForJob(res.jobId);
|
|
339
|
+
if (job.status === 'failed' || !result) {
|
|
340
|
+
const reason = job.error ?? 'unknown error';
|
|
341
|
+
return { content: [{ type: 'text', text: `Could not generate onboarding brief: ${reason}` }] };
|
|
342
|
+
}
|
|
343
|
+
const insight = result;
|
|
344
|
+
return { content: [{ type: 'text', text: `# ${insight.title}\n\n${insight.summary}` }] };
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
return { content: [{ type: 'text', text: `Failed: ${err.message}` }] };
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
// ── Tool: generate architecture insight ─────────────────────────────
|
|
351
|
+
server.tool('generate_architecture_insight', 'Generate an architecture insight AI insight for the specified project scope.', {
|
|
352
|
+
project_id: z.string().describe('The project ID scope to generate the architecture insight for.'),
|
|
353
|
+
sources: z.array(SourceEnum).optional().describe('Filter events to specific sources'),
|
|
354
|
+
focus_question: z.string().optional().describe('Specific architectural question or component to focus on'),
|
|
355
|
+
lookback_days: z.number().optional().describe('Number of days of history to review'),
|
|
356
|
+
max_events: z.number().optional().describe('Maximum events to process'),
|
|
357
|
+
}, async ({ project_id, sources, focus_question, lookback_days, max_events }) => {
|
|
358
|
+
try {
|
|
359
|
+
const res = await api.generateInsight(project_id, 'architecture', {
|
|
360
|
+
sources,
|
|
361
|
+
focusQuestion: focus_question,
|
|
362
|
+
lookbackDays: lookback_days,
|
|
363
|
+
maxEvents: max_events,
|
|
364
|
+
});
|
|
365
|
+
const { job, result } = await api.waitForJob(res.jobId);
|
|
366
|
+
if (job.status === 'failed' || !result) {
|
|
367
|
+
const reason = job.error ?? 'unknown error';
|
|
368
|
+
return { content: [{ type: 'text', text: `Could not generate architecture insight: ${reason}` }] };
|
|
369
|
+
}
|
|
370
|
+
const insight = result;
|
|
371
|
+
return { content: [{ type: 'text', text: `# ${insight.title}\n\n${insight.summary}` }] };
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
return { content: [{ type: 'text', text: `Failed: ${err.message}` }] };
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
// ── Tool: list insights ─────────────────────────────────────────────
|
|
378
|
+
server.tool('list_insights', 'List project AI insights for a selected project scope.', {
|
|
379
|
+
project_id: z.string().describe('The project ID context to list insights for.'),
|
|
380
|
+
kind: z.enum(['onboarding_brief', 'cross_source_correlation', 'architecture_insight']).optional().describe('Filter by insight kind'),
|
|
381
|
+
status: z.enum(['active', 'archived', 'completed']).default('active').describe('Filter by status'),
|
|
382
|
+
limit: z.number().min(1).max(50).default(20).describe('Max number of insights to return'),
|
|
383
|
+
}, async ({ project_id, kind, status, limit }) => {
|
|
384
|
+
try {
|
|
385
|
+
const { insights } = await api.listInsights(project_id, kind, status, limit);
|
|
386
|
+
if (insights.length === 0) {
|
|
387
|
+
return { content: [{ type: 'text', text: `No ${status} insights found.` }] };
|
|
388
|
+
}
|
|
389
|
+
const text = insights.map((insight) => {
|
|
390
|
+
return `## ${insight.title} (${insight.kind})\n**Status:** ${insight.status} · **Created:** ${new Date(insight.created_at).toLocaleString()}\n\n${insight.summary}`;
|
|
391
|
+
}).join('\n---\n\n');
|
|
392
|
+
return { content: [{ type: 'text', text }] };
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
return { content: [{ type: 'text', text: `Failed to list insights: ${err.message}` }] };
|
|
396
|
+
}
|
|
397
|
+
});
|
|
284
398
|
// ── Start ────────────────────────────────────────────────────────────
|
|
285
399
|
const transport = new StdioServerTransport();
|
|
286
400
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowrelay/mcp-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Flow Relay MCP Server for Claude Desktop and Claude Code
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Flow Relay MCP Server for Claude Desktop and Claude Code – handoffs, integrations, and context events via natural conversation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Adriano Sorbello",
|