@flowrelay/mcp-server 0.4.0 → 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 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.4.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 talks to flowrelay.it/api/v1/*
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: Array<{
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
- handoff: {
61
- id: string;
62
- user_id: string;
63
- project_id: string | null;
64
- project_name?: string | null;
65
- scope_type?: "personal" | "project";
66
- title: string;
67
- summary: string;
68
- sources: string[];
69
- key_changes?: string[];
70
- decisions: string[];
71
- open_questions: string[];
72
- next_steps: string[];
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 talks to flowrelay.it/api/v1/*
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 request(path, options) {
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
- return res.json();
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
- return this.request('/handoffs', {
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.4.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 { handoff } = await api.generateHandoff(sources, filters, resolved.projectId);
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} status: ${status}, providers connected: ${providers}`;
239
+ return `- **${i.source}**${name} status: ${status}, providers connected: ${providers}`;
228
240
  }
229
- return `- **${i.source}**${name} connected ${new Date(i.connected_at).toLocaleDateString()}`;
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 ? ` ${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,14 +1,14 @@
1
1
  {
2
2
  "name": "@flowrelay/mcp-server",
3
- "version": "0.4.0",
4
- "description": "Flow Relay MCP Server for Claude Desktop and Claude Code handoffs, integrations, and context events via natural conversation.",
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",
8
8
  "homepage": "https://www.flowrelay.it",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "https://github.com/AdrianSorbello/flow-relay"
11
+ "url": "https://github.com/atrisorb/flow-relay.git"
12
12
  },
13
13
  "keywords": [
14
14
  "mcp",