Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/data-studio-mcp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/data-studio-mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@geek-fun/data-studio-mcp",
"version": "0.1.2",
"version": "0.1.3",
"description": "Unified MCP server for SQL (PostgreSQL, MySQL, SQL Server, SQLite) and NoSQL (Elasticsearch, MongoDB, DynamoDB) — powered by dockit and sqlkit desktop apps",
"keywords": [
"mcp",
Expand Down
39 changes: 27 additions & 12 deletions packages/data-studio-mcp/src/backends.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,40 @@
import type { BackendInfo } from './discovery.js';

export interface BridgeToolDef {
export type BridgeToolDef = {
name: string;
description: string;
inputSchema: object;
metadata?: {
riskLevel?: string;
requiredPermission?: string;
};
}
};

export interface InvokeResult {
export type InvokeResult = {
status: number;
data?: unknown;
message?: string;
}
};

export interface BackendClient {
export type BridgeConnection = {
id: unknown;
name: string;
type: string;
};

export type BridgeCatalog = {
tools: BridgeToolDef[];
connections: BridgeConnection[];
};

export type BackendClient = {
readonly name: string;
readonly baseUrl: string;
listTools(): Promise<BridgeToolDef[]>;
listTools(): Promise<BridgeCatalog>;
invokeTool(name: string, args: Record<string, unknown>): Promise<InvokeResult>;
}
};

export function createBackendClient(info: BackendInfo): BackendClient {
export const createBackendClient = (info: BackendInfo): BackendClient => {
return {
get name() {
return info.name;
Expand All @@ -33,7 +44,7 @@ export function createBackendClient(info: BackendInfo): BackendClient {
return info.baseUrl;
},

async listTools(): Promise<BridgeToolDef[]> {
async listTools(): Promise<BridgeCatalog> {
const res = await fetch(`${info.baseUrl}/tools`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand All @@ -44,8 +55,12 @@ export function createBackendClient(info: BackendInfo): BackendClient {
throw new Error(`Backend ${info.name} returned HTTP ${res.status}: ${res.statusText}`);
}

const body = (await res.json()) as { tools: BridgeToolDef[]; connections: unknown[] };
return body.tools ?? [];
const body = (await res.json()) as {
tools: BridgeToolDef[];
connections?: BridgeConnection[];
};

return { tools: body.tools ?? [], connections: body.connections ?? [] };
},

async invokeTool(name: string, args: Record<string, unknown>): Promise<InvokeResult> {
Expand All @@ -59,4 +74,4 @@ export function createBackendClient(info: BackendInfo): BackendClient {
return (await res.json()) as InvokeResult;
},
};
}
};
24 changes: 24 additions & 0 deletions packages/data-studio-mcp/src/connections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { BackendClient } from './backends.js';

export type MergedConnection = {
backend: string;
id: unknown;
name: string;
type: string;
};

export const listConnections = async (
clients: Map<string, BackendClient>,
): Promise<MergedConnection[]> => {
const results = await Promise.all(
[...clients.entries()].map(async ([backend, client]) => {
try {
const { connections } = await client.listTools();
return connections.map(conn => ({ backend, ...conn }));
} catch {
return [];
}
}),
);
return results.flat();
};
16 changes: 8 additions & 8 deletions packages/data-studio-mcp/src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

export interface BackendInfo {
export type BackendInfo = {
name: 'dockit' | 'sqlkit';
port: number;
baseUrl: string;
}
};

const BACKENDS: ReadonlyArray<{
name: 'dockit' | 'sqlkit';
Expand All @@ -18,17 +18,17 @@ const BACKENDS: ReadonlyArray<{
{ name: 'sqlkit', bundleId: 'club.geekfun.sqlkit', defaultPort: 9121, envVar: 'SQLKIT_MCP_PORT' },
];

export function appDataDir(bundleId: string): string {
export const appDataDir = (bundleId: string): string => {
const home = os.homedir();
const platform = os.platform();

if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', bundleId);
if (platform === 'linux') return path.join(home, '.local', 'share', bundleId);
if (platform === 'win32') return path.join(home, 'AppData', 'Roaming', bundleId);
return path.join(home, '.local', 'share', bundleId);
}
};

function discoverBackend(cfg: (typeof BACKENDS)[number]): BackendInfo | null {
const discoverBackend = (cfg: (typeof BACKENDS)[number]): BackendInfo | null => {
const envPort = process.env[cfg.envVar];
if (envPort) {
const port = parseInt(envPort, 10);
Expand All @@ -49,8 +49,8 @@ function discoverBackend(cfg: (typeof BACKENDS)[number]): BackendInfo | null {
}

return null;
}
};

export function discoverBackends(): BackendInfo[] {
export const discoverBackends = (): BackendInfo[] => {
return BACKENDS.map(discoverBackend).filter((b): b is BackendInfo => b !== null);
}
};
33 changes: 24 additions & 9 deletions packages/data-studio-mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {
import { discoverBackends } from './discovery.js';
import { createBackendClient } from './backends.js';
import { buildToolCatalog } from './tools.js';
import { listConnections } from './connections.js';

const LIST_CONNECTIONS_TOOL = 'data_studio__list_connections';

const errorResult = (text: string): CallToolResult => ({
content: [{ type: 'text', text }],
Expand Down Expand Up @@ -50,22 +53,34 @@ const main = async (): Promise<void> => {
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: tools.map(t => ({
name: t.name,
description: t.description,
inputSchema: {
type: 'object' as const,
properties: (t.inputSchema as Record<string, unknown>)?.properties as
Record<string, object> | undefined,
required: (t.inputSchema as Record<string, unknown>)?.required as string[] | undefined,
tools: [
{
name: LIST_CONNECTIONS_TOOL,
description:
'List all database connections from both dockit and sqlkit. Each entry has { backend, id, name, type } — use id as connection_id when calling database tools.',
inputSchema: { type: 'object' as const, properties: {}, required: [] },
},
})),
...tools.map(t => ({
name: t.name,
description: t.description,
inputSchema: {
type: 'object' as const,
properties: (t.inputSchema as Record<string, unknown>)?.properties as
Record<string, object> | undefined,
required: (t.inputSchema as Record<string, unknown>)?.required as string[] | undefined,
},
})),
],
}));

server.setRequestHandler(CallToolRequestSchema, async request => {
const toolName = request.params.name;
const args = (request.params.arguments ?? {}) as Record<string, unknown>;

if (toolName === LIST_CONNECTIONS_TOOL) {
return okResult(await listConnections(clients));
}

const route = routeMap.get(toolName);
if (!route) return errorResult(`Unknown tool: ${toolName}`);

Expand Down
22 changes: 12 additions & 10 deletions packages/data-studio-mcp/src/tools.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import type { BackendInfo } from './discovery.js';
import { createBackendClient, type BridgeToolDef } from './backends.js';

export interface McpToolDef {
export type McpToolDef = {
name: string;
description: string;
inputSchema: object;
backendName: string;
internalName: string;
}
};

interface CatalogEntry {
type CatalogEntry = {
tool: McpToolDef;
route: { backendName: 'dockit' | 'sqlkit'; internalName: string };
}
};

async function fetchBackendCatalog(backend: BackendInfo): Promise<readonly CatalogEntry[]> {
const fetchBackendCatalog = async (backend: BackendInfo): Promise<readonly CatalogEntry[]> => {
const client = createBackendClient(backend);
let bridgeTools: BridgeToolDef[];
try {
bridgeTools = await client.listTools();
bridgeTools = (await client.listTools()).tools;
} catch (err) {
console.error(`Failed to fetch tools from ${backend.name}:`, err);
return [];
Expand All @@ -37,15 +37,17 @@ async function fetchBackendCatalog(backend: BackendInfo): Promise<readonly Catal
route: { backendName: backend.name, internalName: bt.name },
} satisfies CatalogEntry;
});
}
};

export async function buildToolCatalog(backends: readonly BackendInfo[]): Promise<{
export const buildToolCatalog = async (
backends: readonly BackendInfo[],
): Promise<{
tools: McpToolDef[];
routeMap: Map<string, { backendName: 'dockit' | 'sqlkit'; internalName: string }>;
}> {
}> => {
const entries = (await Promise.all(backends.map(fetchBackendCatalog))).flat();
return {
tools: entries.map(e => e.tool),
routeMap: new Map(entries.map(e => [e.tool.name, e.route])),
};
}
};
15 changes: 8 additions & 7 deletions packages/data-studio-mcp/tests/backends.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createBackendClient } from '../src/backends.js';
let mockServer: Server | null = null;
let mockPort = 0;

function startMock(failTools = false): Promise<number> {
const startMock = (failTools = false): Promise<number> => {
return new Promise(resolve => {
mockServer = createServer((req, res) => {
if (req.method === 'POST' && req.url === '/tools') {
Expand All @@ -25,7 +25,7 @@ function startMock(failTools = false): Promise<number> {
metadata: { riskLevel: 'safe' },
},
],
connections: [],
connections: [{ id: 1, name: 'prod-es', type: 'ELASTICSEARCH' }],
}),
);
} else if (req.method === 'POST' && req.url === '/invoke') {
Expand All @@ -46,24 +46,25 @@ function startMock(failTools = false): Promise<number> {
resolve(typeof addr === 'object' ? addr!.port : 0);
});
});
}
};

afterAll(() => {
mockServer?.close();
});

describe('BackendClient', () => {
it('listTools returns tools from the bridge', async () => {
it('listTools returns tools and connections from the bridge', async () => {
mockPort = await startMock();
const client = createBackendClient({
name: 'dockit',
port: mockPort,
baseUrl: `http://127.0.0.1:${mockPort}`,
});

const tools = await client.listTools();
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe('es__search');
const catalog = await client.listTools();
expect(catalog.tools).toHaveLength(1);
expect(catalog.tools[0].name).toBe('es__search');
expect(catalog.connections).toEqual([{ id: 1, name: 'prod-es', type: 'ELASTICSEARCH' }]);
});

it('listTools throws when bridge returns error', async () => {
Expand Down
Loading
Loading