@ferrlabs/mcp 5.1.1 → 5.2.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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=context.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.test.d.ts","sourceRoot":"","sources":["../../../src/auth/__tests__/context.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ describe('per-request bearer token takes precedence over env/persisted', () => {
3
+ beforeEach(() => {
4
+ delete process.env.FERRLABS_API_TOKEN;
5
+ delete process.env.FERRFLOW_API_TOKEN;
6
+ process.env.FERRLABS_MCP_NO_PERSIST = '1';
7
+ process.env.FERRLABS_MCP_NO_OAUTH = '1';
8
+ });
9
+ it('returns the bearer from AuthContext when set', async () => {
10
+ const { runWithAuthContext } = await import('../context.js');
11
+ const { getToken } = await import('../index.js');
12
+ const result = await runWithAuthContext({ bearerToken: 'fl_req_token' }, () => getToken());
13
+ expect(result).toBe('fl_req_token');
14
+ });
15
+ it('bearer from context wins over FERRLABS_API_TOKEN env', async () => {
16
+ process.env.FERRLABS_API_TOKEN = 'fl_env';
17
+ const { runWithAuthContext } = await import('../context.js');
18
+ const { getToken } = await import('../index.js');
19
+ const result = await runWithAuthContext({ bearerToken: 'fl_req' }, () => getToken());
20
+ expect(result).toBe('fl_req');
21
+ });
22
+ it('falls back to env when context has no bearer', async () => {
23
+ process.env.FERRLABS_API_TOKEN = 'fl_env_only';
24
+ const { runWithAuthContext } = await import('../context.js');
25
+ const { getToken } = await import('../index.js');
26
+ const result = await runWithAuthContext({}, () => getToken());
27
+ expect(result).toBe('fl_env_only');
28
+ });
29
+ });
@@ -0,0 +1,9 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ interface AuthContext {
3
+ bearerToken?: string;
4
+ }
5
+ export declare const authContext: AsyncLocalStorage<AuthContext>;
6
+ export declare function runWithAuthContext<T>(ctx: AuthContext, fn: () => Promise<T>): Promise<T>;
7
+ export declare function getRequestBearerToken(): string | undefined;
8
+ export {};
9
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../src/auth/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAErD,UAAU,WAAW;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,eAAO,MAAM,WAAW,gCAAuC,CAAC;AAEhE,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,GAAG,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAExF;AAED,wBAAgB,qBAAqB,IAAI,MAAM,GAAG,SAAS,CAE1D"}
@@ -0,0 +1,8 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ export const authContext = new AsyncLocalStorage();
3
+ export function runWithAuthContext(ctx, fn) {
4
+ return authContext.run(ctx, fn);
5
+ }
6
+ export function getRequestBearerToken() {
7
+ return authContext.getStore()?.bearerToken;
8
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAMA,wBAAsB,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,CAgChD;AAED,wBAAgB,yBAAyB,IAAI,IAAI,CAGhD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAOA,wBAAsB,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,CAuChD;AAED,wBAAgB,yBAAyB,IAAI,IAAI,CAGhD"}
@@ -1,8 +1,15 @@
1
1
  import { readPersistedToken, writePersistedToken } from './persistence.js';
2
2
  import { runLoopbackOauthFlow } from './oauth.js';
3
+ import { getRequestBearerToken } from './context.js';
3
4
  let cached = null;
4
5
  let inFlight = null;
5
6
  export async function getToken() {
7
+ const perRequest = getRequestBearerToken();
8
+ if (perRequest)
9
+ return perRequest;
10
+ if (process.env.FERRLABS_MCP_MODE === 'http') {
11
+ throw new Error('Bearer token required. Send `Authorization: Bearer fl_...` on each request.');
12
+ }
6
13
  if (cached)
7
14
  return cached;
8
15
  const envToken = process.env.FERRLABS_API_TOKEN ?? process.env.FERRFLOW_API_TOKEN;
package/dist/index.js CHANGED
@@ -1,23 +1,20 @@
1
1
  #!/usr/bin/env node
2
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
- import { registerStatsTools } from './tools/stats.js';
4
- import { registerTokenTools } from './tools/tokens.js';
5
- import { registerOrgsTools } from './tools/orgs.js';
6
- import { registerVaultsTools } from './tools/vaults.js';
7
- import { registerIssuesTools } from './tools/issues.js';
8
- import { registerSubscriptionsTools } from './tools/subscriptions.js';
9
- import { registerDocsTools } from './tools/docs.js';
10
- const server = new McpServer({
11
- name: 'ferrlabs',
12
- version: '4.0.0',
2
+ import { startStdioServer } from './transports/stdio.js';
3
+ import { startHttpServer } from './transports/http.js';
4
+ async function main() {
5
+ const mode = process.env.FERRLABS_MCP_MODE ?? (process.argv.includes('--http') ? 'http' : 'stdio');
6
+ if (mode === 'http') {
7
+ const port = Number(process.env.PORT ?? '3000');
8
+ await startHttpServer({
9
+ port,
10
+ host: process.env.HOST,
11
+ stateless: process.env.FERRLABS_MCP_STATEFUL !== '1',
12
+ });
13
+ return;
14
+ }
15
+ await startStdioServer();
16
+ }
17
+ main().catch((err) => {
18
+ console.error('ferrlabs-mcp fatal:', err instanceof Error ? err.message : err);
19
+ process.exit(1);
13
20
  });
14
- registerStatsTools(server);
15
- registerTokenTools(server);
16
- registerOrgsTools(server);
17
- registerVaultsTools(server);
18
- registerIssuesTools(server);
19
- registerSubscriptionsTools(server);
20
- registerDocsTools(server);
21
- const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
22
- const transport = new StdioServerTransport();
23
- await server.connect(transport);
@@ -0,0 +1,3 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function createMcpServer(): McpServer;
3
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAWpE,wBAAgB,eAAe,IAAI,SAAS,CAU3C"}
package/dist/server.js ADDED
@@ -0,0 +1,20 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { registerStatsTools } from './tools/stats.js';
3
+ import { registerTokenTools } from './tools/tokens.js';
4
+ import { registerOrgsTools } from './tools/orgs.js';
5
+ import { registerVaultsTools } from './tools/vaults.js';
6
+ import { registerIssuesTools } from './tools/issues.js';
7
+ import { registerSubscriptionsTools } from './tools/subscriptions.js';
8
+ import { registerDocsTools } from './tools/docs.js';
9
+ const VERSION = '5.1.1';
10
+ export function createMcpServer() {
11
+ const server = new McpServer({ name: 'ferrlabs', version: VERSION });
12
+ registerStatsTools(server);
13
+ registerTokenTools(server);
14
+ registerOrgsTools(server);
15
+ registerVaultsTools(server);
16
+ registerIssuesTools(server);
17
+ registerSubscriptionsTools(server);
18
+ registerDocsTools(server);
19
+ return server;
20
+ }
@@ -0,0 +1,7 @@
1
+ export interface HttpServerOptions {
2
+ port: number;
3
+ host?: string;
4
+ stateless?: boolean;
5
+ }
6
+ export declare function startHttpServer(opts: HttpServerOptions): Promise<void>;
7
+ //# sourceMappingURL=http.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAoBD,wBAAsB,eAAe,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA0D5E"}
@@ -0,0 +1,89 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
+ import { createMcpServer } from '../server.js';
5
+ import { runWithAuthContext } from '../auth/context.js';
6
+ function extractBearer(req) {
7
+ const header = req.headers['authorization'];
8
+ if (typeof header !== 'string')
9
+ return undefined;
10
+ const match = /^Bearer\s+(.+)$/i.exec(header);
11
+ return match?.[1];
12
+ }
13
+ async function readJsonBody(req) {
14
+ if (req.method !== 'POST')
15
+ return undefined;
16
+ return new Promise((resolve, reject) => {
17
+ const chunks = [];
18
+ req.on('data', (c) => chunks.push(c));
19
+ req.on('end', () => {
20
+ const raw = Buffer.concat(chunks).toString('utf8');
21
+ if (!raw)
22
+ return resolve(undefined);
23
+ try {
24
+ resolve(JSON.parse(raw));
25
+ }
26
+ catch (err) {
27
+ reject(err);
28
+ }
29
+ });
30
+ req.on('error', reject);
31
+ });
32
+ }
33
+ export async function startHttpServer(opts) {
34
+ const { port, host = '0.0.0.0', stateless = true } = opts;
35
+ const transports = new Map();
36
+ async function handleMcpRequest(req, res) {
37
+ const sessionId = req.headers['mcp-session-id'] ?? undefined;
38
+ let transport = sessionId ? transports.get(sessionId) : undefined;
39
+ if (!transport) {
40
+ transport = new StreamableHTTPServerTransport({
41
+ sessionIdGenerator: stateless ? undefined : randomUUID,
42
+ onsessioninitialized: stateless
43
+ ? undefined
44
+ : (newSessionId) => {
45
+ if (transport)
46
+ transports.set(newSessionId, transport);
47
+ },
48
+ });
49
+ transport.onclose = () => {
50
+ if (sessionId)
51
+ transports.delete(sessionId);
52
+ };
53
+ const server = createMcpServer();
54
+ await server.connect(transport);
55
+ }
56
+ const body = await readJsonBody(req);
57
+ const bearerToken = extractBearer(req);
58
+ await runWithAuthContext({ bearerToken }, () => transport.handleRequest(req, res, body));
59
+ }
60
+ const httpServer = createServer((req, res) => {
61
+ const url = req.url ?? '/';
62
+ if (url === '/health' || url === '/livez' || url === '/readyz') {
63
+ res.writeHead(200, { 'Content-Type': 'application/json' });
64
+ res.end(JSON.stringify({ status: 'ok', service: 'ferrlabs-mcp' }));
65
+ return;
66
+ }
67
+ if (url === '/mcp' || url.startsWith('/mcp?')) {
68
+ handleMcpRequest(req, res).catch((err) => {
69
+ if (!res.headersSent) {
70
+ res.writeHead(500, { 'Content-Type': 'application/json' });
71
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
72
+ }
73
+ else {
74
+ res.end();
75
+ }
76
+ });
77
+ return;
78
+ }
79
+ res.writeHead(404, { 'Content-Type': 'application/json' });
80
+ res.end(JSON.stringify({ error: 'Not found' }));
81
+ });
82
+ await new Promise((resolve, reject) => {
83
+ httpServer.once('error', reject);
84
+ httpServer.listen(port, host, () => {
85
+ console.error(`ferrlabs-mcp HTTP transport listening on http://${host}:${port}/mcp`);
86
+ resolve();
87
+ });
88
+ });
89
+ }
@@ -0,0 +1,2 @@
1
+ export declare function startStdioServer(): Promise<void>;
2
+ //# sourceMappingURL=stdio.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stdio.d.ts","sourceRoot":"","sources":["../../src/transports/stdio.ts"],"names":[],"mappings":"AAGA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
@@ -0,0 +1,7 @@
1
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2
+ import { createMcpServer } from '../server.js';
3
+ export async function startStdioServer() {
4
+ const server = createMcpServer();
5
+ const transport = new StdioServerTransport();
6
+ await server.connect(transport);
7
+ }
package/package.json CHANGED
@@ -50,7 +50,7 @@
50
50
  "url": "https://github.com/FerrLabs/MCP.git"
51
51
  },
52
52
  "type": "module",
53
- "version": "5.1.1",
53
+ "version": "5.2.1",
54
54
  "scripts": {
55
55
  "build": "tsc",
56
56
  "ci:local": "pnpm typecheck && pnpm test && pnpm build",