@flowrelay/mcp-server 0.3.9 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # Flow Relay MCP Server
2
+
3
+ Flow Relay MCP Server adds project-aware, multi-tenant Flow Relay tools to MCP clients such as Claude Desktop and Claude Code.
4
+
5
+ It connects to Flow Relay API v1 using an API key and supports both:
6
+
7
+ - Personal stream scope
8
+ - Project scope (personal project or organization project)
9
+
10
+ ## Package
11
+
12
+ - Name: @flowrelay/mcp-server
13
+ - Version: 0.4.1
14
+
15
+ ## What Is Included
16
+
17
+ The server currently exposes these tools:
18
+
19
+ - get_workspace_context
20
+ - list_projects
21
+ - set_active_project
22
+ - list_handoffs
23
+ - generate_handoff
24
+ - list_integrations
25
+ - list_events
26
+ - discord_list_channels
27
+ - discord_send_message
28
+
29
+ ## Environment Variables
30
+
31
+ Required:
32
+
33
+ - FLOWRELAY_API_KEY
34
+
35
+ Optional:
36
+
37
+ - FLOWRELAY_PROJECT_ID
38
+ - FLOWRELAY_BASE_URL
39
+
40
+ If FLOWRELAY_PROJECT_ID is set, it becomes the default project context for project-aware tools unless you override it per call.
41
+
42
+ ## Quick Start (Claude Desktop)
43
+
44
+ Add this to your claude_desktop_config.json:
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "flowrelay": {
50
+ "command": "npx",
51
+ "args": ["-y", "@flowrelay/mcp-server"],
52
+ "env": {
53
+ "FLOWRELAY_API_KEY": "fr_your_api_key_here",
54
+ "FLOWRELAY_PROJECT_ID": "optional_project_id"
55
+ }
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ ## Multi-Tenant Behavior
62
+
63
+ - If no project is active, tools run in personal stream scope.
64
+ - You can select a project during the MCP session with set_active_project.
65
+ - You can override scope per call by passing project_id where supported.
66
+
67
+ Recommended flow:
68
+
69
+ 1. Call get_workspace_context
70
+ 2. Call list_projects
71
+ 3. Call set_active_project
72
+ 4. Run handoff and query tools in the selected scope
73
+
74
+ ## Local Development
75
+
76
+ From this folder:
77
+
78
+ ```bash
79
+ npm install
80
+ npm run build
81
+ ```
82
+
83
+ Create a tarball package:
84
+
85
+ ```bash
86
+ npm pack
87
+ ```
88
+
89
+ ## Troubleshooting
90
+
91
+ - Error: Missing FLOWRELAY_API_KEY
92
+ - Set FLOWRELAY_API_KEY in your MCP client configuration.
93
+ - Project not found or inaccessible
94
+ - Run list_projects and use one of the returned IDs.
95
+ - No events or handoffs returned
96
+ - Verify active scope and data availability in that scope.
97
+
98
+ ## Related Docs
99
+
100
+ - Repository overview: ../README.md
101
+ - Release notes: ../RELEASE_NOTES_v0.4.0.md
package/dist/api.d.ts CHANGED
@@ -1,14 +1,46 @@
1
1
  /**
2
2
  * Flow Relay API client — talks to flowrelay.it/api/v1/*
3
3
  */
4
+ export type AccountType = 'personal' | 'business';
5
+ export type AccessRole = 'owner' | 'admin' | 'member';
6
+ export interface TenantOrganization {
7
+ id: string;
8
+ name: string;
9
+ slug: string;
10
+ role: 'admin' | 'member';
11
+ is_temporary_admin: boolean;
12
+ }
13
+ export interface TenantProject {
14
+ id: string;
15
+ name: string;
16
+ slug: string;
17
+ description: string;
18
+ organization_id: string | null;
19
+ organization_name: string | null;
20
+ organization_slug: string | null;
21
+ project_type: 'personal' | 'organization';
22
+ access_role: AccessRole;
23
+ created_at: string;
24
+ updated_at: string;
25
+ }
26
+ export interface TenantContext {
27
+ account_type: AccountType;
28
+ organizations: TenantOrganization[];
29
+ projects: TenantProject[];
30
+ }
4
31
  export declare class FlowRelayAPI {
5
32
  private baseUrl;
6
33
  private apiKey;
7
34
  constructor(apiKey: string, baseUrl?: string);
8
35
  private request;
9
- listHandoffs(status?: string, limit?: number): Promise<{
36
+ listProjects(): Promise<TenantContext>;
37
+ listHandoffs(status?: string, limit?: number, projectId?: string | null): Promise<{
10
38
  handoffs: Array<{
11
39
  id: string;
40
+ user_id: string;
41
+ project_id: string | null;
42
+ project_name?: string | null;
43
+ scope_type?: "personal" | "project";
12
44
  title: string;
13
45
  summary: string;
14
46
  status: string;
@@ -24,9 +56,13 @@ export declare class FlowRelayAPI {
24
56
  generateHandoff(sources?: string[], filters?: Record<string, {
25
57
  projects?: string[];
26
58
  eventTypes?: string[];
27
- }>): Promise<{
59
+ }>, projectId?: string | null): Promise<{
28
60
  handoff: {
29
61
  id: string;
62
+ user_id: string;
63
+ project_id: string | null;
64
+ project_name?: string | null;
65
+ scope_type?: "personal" | "project";
30
66
  title: string;
31
67
  summary: string;
32
68
  sources: string[];
@@ -37,17 +73,23 @@ export declare class FlowRelayAPI {
37
73
  created_at: string;
38
74
  };
39
75
  }>;
40
- listIntegrations(): Promise<{
76
+ listIntegrations(projectId?: string | null): Promise<{
41
77
  integrations: Array<{
42
78
  source: string;
43
79
  workspace_id: string | null;
44
80
  workspace_name: string | null;
45
81
  connected_at: string;
82
+ scope?: "personal" | "project";
83
+ resource_type?: string | null;
84
+ connection_status?: string | null;
85
+ providers_connected?: number;
86
+ last_validated_at?: string | null;
46
87
  }>;
47
88
  }>;
48
- listEvents(source?: string, limit?: number): Promise<{
89
+ listEvents(source?: string, limit?: number, projectId?: string | null): Promise<{
49
90
  events: Array<{
50
91
  id: string;
92
+ user_id?: string;
51
93
  source: string;
52
94
  event_type: string;
53
95
  title: string;
package/dist/api.js CHANGED
@@ -23,30 +23,49 @@ 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
+ }
26
29
  return res.json();
27
30
  }
28
- async listHandoffs(status = 'active', limit = 10) {
29
- return this.request(`/handoffs?status=${status}&limit=${limit}`);
31
+ async listProjects() {
32
+ return this.request('/projects');
30
33
  }
31
- async generateHandoff(sources, filters) {
34
+ async listHandoffs(status = 'active', limit = 10, projectId) {
35
+ const params = new URLSearchParams();
36
+ params.set('status', status);
37
+ params.set('limit', String(limit));
38
+ if (projectId)
39
+ params.set('project_id', projectId);
40
+ return this.request(`/handoffs?${params.toString()}`);
41
+ }
42
+ async generateHandoff(sources, filters, projectId) {
32
43
  const body = {};
33
44
  if (sources?.length)
34
45
  body.sources = sources;
35
46
  if (filters && Object.keys(filters).length > 0)
36
47
  body.filters = filters;
48
+ if (projectId)
49
+ body.project_id = projectId;
37
50
  return this.request('/handoffs', {
38
51
  method: 'POST',
39
52
  body: JSON.stringify(body),
40
53
  });
41
54
  }
42
- async listIntegrations() {
43
- return this.request('/integrations');
55
+ async listIntegrations(projectId) {
56
+ const params = new URLSearchParams();
57
+ if (projectId)
58
+ params.set('project_id', projectId);
59
+ const suffix = params.toString();
60
+ return this.request(`/integrations${suffix ? `?${suffix}` : ''}`);
44
61
  }
45
- async listEvents(source, limit = 20) {
62
+ async listEvents(source, limit = 20, projectId) {
46
63
  const params = new URLSearchParams();
47
64
  if (source)
48
65
  params.set('source', source);
49
66
  params.set('limit', String(limit));
67
+ if (projectId)
68
+ params.set('project_id', projectId);
50
69
  return this.request(`/events?${params}`);
51
70
  }
52
71
  async discordListChannels() {
package/dist/index.js CHANGED
@@ -3,6 +3,43 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
5
  import { FlowRelayAPI } from './api.js';
6
+ const SOURCES = [
7
+ 'github',
8
+ 'slack',
9
+ 'discord',
10
+ 'linear',
11
+ 'notion',
12
+ 'confluence',
13
+ 'jira',
14
+ 'gitlab',
15
+ 'bitbucket',
16
+ 'azure_devops',
17
+ 'figma',
18
+ 'microsoft_outlook',
19
+ 'microsoft_teams',
20
+ 'sentry',
21
+ 'datadog',
22
+ ];
23
+ const SourceEnum = z.enum(SOURCES);
24
+ function normalizeProjectId(value) {
25
+ if (typeof value !== 'string')
26
+ return null;
27
+ const trimmed = value.trim();
28
+ return trimmed.length > 0 ? trimmed : null;
29
+ }
30
+ function formatProjectScope(project) {
31
+ if (!project)
32
+ return 'No project (personal stream)';
33
+ if (project.project_type === 'personal') {
34
+ return `Personal project: ${project.name}`;
35
+ }
36
+ const role = project.access_role === 'admin'
37
+ ? 'admin'
38
+ : project.access_role === 'member'
39
+ ? 'member'
40
+ : 'owner';
41
+ return `Org project: ${project.name} (${project.organization_name ?? 'Unknown org'}, ${role})`;
42
+ }
6
43
  const apiKey = process.env.FLOWRELAY_API_KEY;
7
44
  if (!apiKey) {
8
45
  console.error('Error: FLOWRELAY_API_KEY environment variable is required.');
@@ -10,22 +47,121 @@ if (!apiKey) {
10
47
  process.exit(1);
11
48
  }
12
49
  const api = new FlowRelayAPI(apiKey, process.env.FLOWRELAY_BASE_URL);
50
+ let activeProjectId = normalizeProjectId(process.env.FLOWRELAY_PROJECT_ID);
13
51
  const server = new McpServer({
14
52
  name: 'flowrelay',
15
- version: '0.3.9',
53
+ version: '0.4.1',
54
+ });
55
+ async function getTenantContext() {
56
+ const context = await api.listProjects();
57
+ if (activeProjectId && !context.projects.some((project) => project.id === activeProjectId)) {
58
+ activeProjectId = null;
59
+ }
60
+ return context;
61
+ }
62
+ async function resolveProject(projectId) {
63
+ const explicitProjectId = normalizeProjectId(projectId);
64
+ const resolvedProjectId = explicitProjectId ?? activeProjectId;
65
+ if (!resolvedProjectId) {
66
+ return { projectId: null, project: null };
67
+ }
68
+ const context = await getTenantContext();
69
+ const project = context.projects.find((candidate) => candidate.id === resolvedProjectId) ?? null;
70
+ if (!project) {
71
+ activeProjectId = null;
72
+ return { projectId: null, project: null };
73
+ }
74
+ return { projectId: resolvedProjectId, project };
75
+ }
76
+ // ── Tool: workspace context ─────────────────────────────────────────
77
+ server.tool('get_workspace_context', 'Show Flow Relay tenant context: personal/business mode, active project scope, and current role.', {}, async () => {
78
+ const context = await getTenantContext();
79
+ const activeProject = activeProjectId
80
+ ? context.projects.find((project) => project.id === activeProjectId) ?? null
81
+ : null;
82
+ const isBusiness = context.account_type === 'business' || context.organizations.length > 0;
83
+ const roleLine = activeProject
84
+ ? `Role: ${activeProject.access_role}`
85
+ : 'Role: n/a (no project selected)';
86
+ const lines = [
87
+ `Account mode: ${isBusiness ? 'business' : 'personal'}`,
88
+ `Organizations: ${context.organizations.length}`,
89
+ `Accessible projects: ${context.projects.length}`,
90
+ `Active scope: ${formatProjectScope(activeProject)}`,
91
+ roleLine,
92
+ ];
93
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
94
+ });
95
+ // ── Tool: list projects ─────────────────────────────────────────────
96
+ server.tool('list_projects', 'List all projects available to this API key, including personal and organization projects with role information.', {}, async () => {
97
+ const context = await getTenantContext();
98
+ if (context.projects.length === 0) {
99
+ return {
100
+ content: [{
101
+ type: 'text',
102
+ text: 'No accessible projects found. Use personal stream or get added to a project team.',
103
+ }],
104
+ };
105
+ }
106
+ const lines = context.projects.map((project) => {
107
+ const active = project.id === activeProjectId ? ' [active]' : '';
108
+ const scope = project.project_type === 'personal'
109
+ ? 'personal'
110
+ : `org:${project.organization_name ?? 'unknown'}`;
111
+ return `- ${project.name} (${scope}, role=${project.access_role})\n id: ${project.id}${active}`;
112
+ });
113
+ return {
114
+ content: [{
115
+ type: 'text',
116
+ text: `Projects:\n${lines.join('\n')}`,
117
+ }],
118
+ };
119
+ });
120
+ // ── Tool: set active project ────────────────────────────────────────
121
+ server.tool('set_active_project', 'Set or clear the active project context used by project-aware tools when project_id is omitted.', {
122
+ project_id: z.string().optional().describe('Project ID to set as active. Omit to clear.'),
123
+ clear: z.boolean().default(false).describe('Clear active project and use no-project personal scope.'),
124
+ }, async ({ project_id, clear }) => {
125
+ if (clear || !normalizeProjectId(project_id)) {
126
+ activeProjectId = null;
127
+ return { content: [{ type: 'text', text: 'Active project cleared. Using no-project personal scope.' }] };
128
+ }
129
+ const context = await getTenantContext();
130
+ const selected = context.projects.find((project) => project.id === project_id);
131
+ if (!selected) {
132
+ return {
133
+ content: [{
134
+ type: 'text',
135
+ text: `Project not found or inaccessible: ${project_id}. Run list_projects first.`,
136
+ }],
137
+ };
138
+ }
139
+ activeProjectId = selected.id;
140
+ return {
141
+ content: [{
142
+ type: 'text',
143
+ text: `Active project set to ${selected.name} (${selected.id}).`,
144
+ }],
145
+ };
16
146
  });
17
147
  // ── Tool: list handoffs ──────────────────────────────────────────────
18
- server.tool('list_handoffs', 'List your Flow Relay handoffs. Returns recent handoffs with summaries, decisions, and next steps.', {
148
+ server.tool('list_handoffs', 'List Flow Relay handoffs in the current tenant scope (personal stream or selected project).', {
19
149
  status: z.enum(['active', 'archived', 'completed']).default('active').describe('Filter by status'),
20
150
  limit: z.number().min(1).max(50).default(10).describe('Max number of handoffs to return'),
21
- }, async ({ status, limit }) => {
22
- const { handoffs } = await api.listHandoffs(status, limit);
151
+ project_id: z.string().optional().describe('Optional project scope override for this call.'),
152
+ }, async ({ status, limit, project_id }) => {
153
+ const resolved = await resolveProject(project_id);
154
+ const { handoffs } = await api.listHandoffs(status, limit, resolved.projectId);
23
155
  if (handoffs.length === 0) {
24
- return { content: [{ type: 'text', text: `No ${status} handoffs found.` }] };
156
+ const scopeLabel = formatProjectScope(resolved.project);
157
+ return { content: [{ type: 'text', text: `No ${status} handoffs found in scope: ${scopeLabel}.` }] };
25
158
  }
26
159
  const text = handoffs.map((h) => {
27
160
  let out = `## ${h.title}\n`;
28
161
  out += `**Status:** ${h.status} · **Sources:** ${h.sources.join(', ') || 'all'}\n`;
162
+ if (h.project_name) {
163
+ out += `**Project:** ${h.project_name}\n`;
164
+ }
29
165
  out += `**Created:** ${new Date(h.created_at).toLocaleString()}\n\n`;
30
166
  out += `${h.summary}\n`;
31
167
  if (h.key_changes?.length)
@@ -41,18 +177,23 @@ server.tool('list_handoffs', 'List your Flow Relay handoffs. Returns recent hand
41
177
  return { content: [{ type: 'text', text }] };
42
178
  });
43
179
  // ── Tool: generate handoff ───────────────────────────────────────────
44
- server.tool('generate_handoff', 'Generate a new context handoff from your connected integrations. Summarizes recent activity into decisions, open questions, and next steps. Optionally filter by specific projects/repos/channels and event types per source.', {
45
- sources: z.array(z.enum(['github', 'slack', 'discord', 'linear', 'notion', 'confluence', 'jira', 'gitlab', 'bitbucket', 'azure_devops', 'figma', 'microsoft_outlook', 'microsoft_teams', 'sentry', 'datadog']))
180
+ server.tool('generate_handoff', 'Generate a new context handoff for personal scope or a selected project. Supports per-source filters.', {
181
+ sources: z.array(SourceEnum)
46
182
  .optional()
47
183
  .describe('Specific sources to include (omit for all connected sources)'),
48
184
  filters: z.record(z.string(), z.object({
49
185
  projects: z.array(z.string()).optional().describe('Filter to specific repos, channels, projects, or teams'),
50
186
  eventTypes: z.array(z.string()).optional().describe('Filter to specific event types (e.g. "push", "issue_created")'),
51
187
  })).optional().describe('Per-source advanced filters'),
52
- }, async ({ sources, filters }) => {
188
+ project_id: z.string().optional().describe('Optional project scope override for this call.'),
189
+ }, async ({ sources, filters, project_id }) => {
53
190
  try {
54
- const { handoff } = await api.generateHandoff(sources, filters);
191
+ const resolved = await resolveProject(project_id);
192
+ const { handoff } = await api.generateHandoff(sources, filters, resolved.projectId);
55
193
  let text = `# ${handoff.title}\n\n${handoff.summary}\n`;
194
+ if (handoff.project_name) {
195
+ text += `\n**Project:** ${handoff.project_name}\n`;
196
+ }
56
197
  text += `\n**Sources:** ${handoff.sources.join(', ')}\n`;
57
198
  if (handoff.key_changes?.length)
58
199
  text += `\n**Key changes:**\n${handoff.key_changes.map((c) => `- ${c}`).join('\n')}\n`;
@@ -67,31 +208,46 @@ server.tool('generate_handoff', 'Generate a new context handoff from your connec
67
208
  }
68
209
  });
69
210
  // ── Tool: list integrations ─────────────────────────────────────────
70
- server.tool('list_integrations', 'List your connected Flow Relay integrations (GitHub, Slack, Discord, Linear, Notion, Confluence, Jira, GitLab, Bitbucket, Azure DevOps, Figma, Microsoft Outlook, Microsoft Teams, Sentry, Datadog).', {}, async () => {
71
- const { integrations } = await api.listIntegrations();
211
+ server.tool('list_integrations', 'List integrations in the current scope. In project scope this returns project resources with health metadata.', {
212
+ project_id: z.string().optional().describe('Optional project scope override for this call.'),
213
+ }, async ({ project_id }) => {
214
+ const resolved = await resolveProject(project_id);
215
+ const { integrations } = await api.listIntegrations(resolved.projectId);
72
216
  if (integrations.length === 0) {
217
+ if (resolved.project) {
218
+ return { content: [{ type: 'text', text: `No integrations configured for project ${resolved.project.name}.` }] };
219
+ }
73
220
  return { content: [{ type: 'text', text: 'No integrations connected. Visit https://www.flowrelay.it/integrations to set up.' }] };
74
221
  }
75
222
  const text = integrations.map((i) => {
76
223
  const name = i.workspace_name ? ` (${i.workspace_name})` : '';
224
+ if (i.scope === 'project') {
225
+ const status = i.connection_status ?? 'unknown';
226
+ const providers = i.providers_connected ?? 0;
227
+ return `- **${i.source}**${name} — status: ${status}, providers connected: ${providers}`;
228
+ }
77
229
  return `- **${i.source}**${name} — connected ${new Date(i.connected_at).toLocaleDateString()}`;
78
230
  }).join('\n');
79
231
  return { content: [{ type: 'text', text: `**Connected integrations:**\n${text}` }] };
80
232
  });
81
233
  // ── Tool: list recent events ─────────────────────────────────────────
82
- server.tool('list_events', 'List recent context events (commits, messages, issues, etc.) from your connected integrations.', {
83
- source: z.enum(['github', 'slack', 'discord', 'linear', 'notion', 'confluence', 'jira', 'gitlab', 'bitbucket', 'azure_devops', 'figma', 'microsoft_outlook', 'microsoft_teams', 'sentry', 'datadog'])
234
+ server.tool('list_events', 'List recent context events in current scope (personal or selected project).', {
235
+ source: SourceEnum
84
236
  .optional()
85
237
  .describe('Filter by integration source'),
86
238
  limit: z.number().min(1).max(100).default(20).describe('Max number of events'),
87
- }, async ({ source, limit }) => {
88
- const { events } = await api.listEvents(source, limit);
239
+ project_id: z.string().optional().describe('Optional project scope override for this call.'),
240
+ }, async ({ source, limit, project_id }) => {
241
+ const resolved = await resolveProject(project_id);
242
+ const { events } = await api.listEvents(source, limit, resolved.projectId);
89
243
  if (events.length === 0) {
90
- return { content: [{ type: 'text', text: `No recent events${source ? ` from ${source}` : ''}.` }] };
244
+ const scopeLabel = formatProjectScope(resolved.project);
245
+ return { content: [{ type: 'text', text: `No recent events${source ? ` from ${source}` : ''} in scope: ${scopeLabel}.` }] };
91
246
  }
92
247
  const text = events.map((e) => {
93
248
  const time = new Date(e.created_at).toLocaleString();
94
- return `- **[${e.source}/${e.event_type}]** ${e.title} _(${time})_`;
249
+ const author = e.user_id ? ` user=${e.user_id}` : '';
250
+ return `- **[${e.source}/${e.event_type}]** ${e.title} _(${time})_${author}`;
95
251
  }).join('\n');
96
252
  return { content: [{ type: 'text', text: `**Recent events:**\n${text}` }] };
97
253
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowrelay/mcp-server",
3
- "version": "0.3.9",
3
+ "version": "0.4.1",
4
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",
@@ -8,7 +8,7 @@
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",