@mixofreality/live-mcp 1.0.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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as net from 'node:net';
3
+ import { BridgeClient } from '../bridge-client.js';
4
+ describe('BridgeClient', () => {
5
+ let mockServer;
6
+ let serverPort;
7
+ beforeEach(async () => {
8
+ mockServer = net.createServer();
9
+ await new Promise((resolve) => {
10
+ mockServer.listen(0, '127.0.0.1', () => resolve());
11
+ });
12
+ const addr = mockServer.address();
13
+ serverPort = addr.port;
14
+ });
15
+ afterEach(async () => {
16
+ await new Promise((resolve) => {
17
+ mockServer.close(() => resolve());
18
+ });
19
+ });
20
+ it('should connect to the bridge server', async () => {
21
+ const serverGotConnection = new Promise((resolve) => {
22
+ mockServer.on('connection', () => resolve());
23
+ });
24
+ const client = new BridgeClient('127.0.0.1', serverPort);
25
+ await client.connect();
26
+ await serverGotConnection;
27
+ expect(client.isConnected()).toBe(true);
28
+ await client.disconnect();
29
+ });
30
+ it('should send a request and receive a response', async () => {
31
+ mockServer.on('connection', (socket) => {
32
+ let buffer = '';
33
+ socket.on('data', (data) => {
34
+ buffer += data.toString();
35
+ const lines = buffer.split('\n');
36
+ buffer = lines.pop();
37
+ for (const line of lines) {
38
+ if (line.trim()) {
39
+ const req = JSON.parse(line);
40
+ const response = JSON.stringify({ id: req.id, result: { value: 120 } });
41
+ socket.write(response + '\n');
42
+ }
43
+ }
44
+ });
45
+ });
46
+ const client = new BridgeClient('127.0.0.1', serverPort);
47
+ await client.connect();
48
+ const result = await client.request('get_property', {
49
+ path: 'live_set',
50
+ property: 'tempo',
51
+ });
52
+ expect(result).toEqual({ value: 120 });
53
+ await client.disconnect();
54
+ });
55
+ it('should handle bridge error responses', async () => {
56
+ mockServer.on('connection', (socket) => {
57
+ let buffer = '';
58
+ socket.on('data', (data) => {
59
+ buffer += data.toString();
60
+ const lines = buffer.split('\n');
61
+ buffer = lines.pop();
62
+ for (const line of lines) {
63
+ if (line.trim()) {
64
+ const req = JSON.parse(line);
65
+ const response = JSON.stringify({
66
+ id: req.id,
67
+ error: { code: -1, message: 'Object not found' },
68
+ });
69
+ socket.write(response + '\n');
70
+ }
71
+ }
72
+ });
73
+ });
74
+ const client = new BridgeClient('127.0.0.1', serverPort);
75
+ await client.connect();
76
+ await expect(client.request('get_property', { path: 'live_set tracks 99', property: 'name' })).rejects.toThrow('Object not found');
77
+ await client.disconnect();
78
+ });
79
+ it('should emit notifications for property changes', async () => {
80
+ mockServer.on('connection', (socket) => {
81
+ setTimeout(() => {
82
+ const notification = JSON.stringify({
83
+ notification: 'property_changed',
84
+ subscriptionId: 'sub-1',
85
+ path: 'live_set',
86
+ property: 'tempo',
87
+ value: 140,
88
+ });
89
+ socket.write(notification + '\n');
90
+ }, 50);
91
+ });
92
+ const client = new BridgeClient('127.0.0.1', serverPort);
93
+ await client.connect();
94
+ const notification = await new Promise((resolve) => {
95
+ client.onNotification((n) => resolve(n));
96
+ });
97
+ expect(notification).toEqual({
98
+ notification: 'property_changed',
99
+ subscriptionId: 'sub-1',
100
+ path: 'live_set',
101
+ property: 'tempo',
102
+ value: 140,
103
+ });
104
+ await client.disconnect();
105
+ });
106
+ it('should report disconnected when not connected', () => {
107
+ const client = new BridgeClient('127.0.0.1', 0);
108
+ expect(client.isConnected()).toBe(false);
109
+ });
110
+ });
@@ -0,0 +1,16 @@
1
+ import type { BridgeMethod, BridgeRequestParams, BridgeNotification, BridgeResult } from '@mixofreality/max4live-nodescript-ts';
2
+ export declare class BridgeClient {
3
+ private readonly host;
4
+ private readonly port;
5
+ private socket;
6
+ private buffer;
7
+ private pending;
8
+ private notificationListeners;
9
+ constructor(host: string, port: number);
10
+ connect(): Promise<void>;
11
+ disconnect(): Promise<void>;
12
+ isConnected(): boolean;
13
+ request(method: BridgeMethod, params: BridgeRequestParams): Promise<BridgeResult>;
14
+ onNotification(listener: (notification: BridgeNotification) => void): void;
15
+ private handleData;
16
+ }
@@ -0,0 +1,91 @@
1
+ import * as net from 'node:net';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { isBridgeNotification } from '@mixofreality/max4live-nodescript-ts';
4
+ export class BridgeClient {
5
+ host;
6
+ port;
7
+ socket = null;
8
+ buffer = '';
9
+ pending = new Map();
10
+ notificationListeners = [];
11
+ constructor(host, port) {
12
+ this.host = host;
13
+ this.port = port;
14
+ }
15
+ async connect() {
16
+ return new Promise((resolve, reject) => {
17
+ const socket = net.createConnection({ host: this.host, port: this.port });
18
+ socket.once('connect', () => {
19
+ this.socket = socket;
20
+ resolve();
21
+ });
22
+ socket.once('error', reject);
23
+ socket.on('data', (data) => this.handleData(data.toString()));
24
+ socket.on('close', () => {
25
+ this.socket = null;
26
+ for (const [, { reject: rej }] of this.pending) {
27
+ rej(new Error('Bridge connection closed'));
28
+ }
29
+ this.pending.clear();
30
+ });
31
+ });
32
+ }
33
+ async disconnect() {
34
+ if (this.socket) {
35
+ return new Promise((resolve) => {
36
+ this.socket.end(() => resolve());
37
+ });
38
+ }
39
+ }
40
+ isConnected() {
41
+ return this.socket !== null && !this.socket.destroyed;
42
+ }
43
+ async request(method, params) {
44
+ if (!this.isConnected()) {
45
+ throw new Error('Not connected to bridge');
46
+ }
47
+ const id = randomUUID();
48
+ const request = JSON.stringify({ id, method, params });
49
+ return new Promise((resolve, reject) => {
50
+ this.pending.set(id, { resolve, reject });
51
+ this.socket.write(request + '\n');
52
+ });
53
+ }
54
+ onNotification(listener) {
55
+ this.notificationListeners.push(listener);
56
+ }
57
+ handleData(data) {
58
+ this.buffer += data;
59
+ const lines = this.buffer.split('\n');
60
+ this.buffer = lines.pop();
61
+ for (const line of lines) {
62
+ if (!line.trim())
63
+ continue;
64
+ let msg;
65
+ try {
66
+ msg = JSON.parse(line);
67
+ }
68
+ catch {
69
+ continue;
70
+ }
71
+ if (isBridgeNotification(msg)) {
72
+ for (const listener of this.notificationListeners) {
73
+ listener(msg);
74
+ }
75
+ }
76
+ else {
77
+ const response = msg;
78
+ const pendingReq = this.pending.get(response.id);
79
+ if (pendingReq) {
80
+ this.pending.delete(response.id);
81
+ if (response.error) {
82
+ pendingReq.reject(new Error(response.error.message));
83
+ }
84
+ else if (response.result) {
85
+ pendingReq.resolve(response.result);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ // MCP server entry point
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { BridgeClient } from './bridge-client.js';
6
+ import { registerAllTools } from './tools/index.js';
7
+ const BRIDGE_HOST = process.env['LIVE_MCP_BRIDGE_HOST'] ?? '127.0.0.1';
8
+ const BRIDGE_PORT = parseInt(process.env['LIVE_MCP_BRIDGE_PORT'] ?? '19740', 10);
9
+ const server = new McpServer({
10
+ name: 'live-mcp',
11
+ version: '1.0.0',
12
+ }, {
13
+ capabilities: { logging: {} },
14
+ });
15
+ const bridge = new BridgeClient(BRIDGE_HOST, BRIDGE_PORT);
16
+ registerAllTools(server, bridge);
17
+ async function main() {
18
+ try {
19
+ await bridge.connect();
20
+ console.error('Connected to M4L bridge');
21
+ }
22
+ catch {
23
+ console.error(`Warning: Could not connect to M4L bridge at ${BRIDGE_HOST}:${BRIDGE_PORT}. Tools will fail until bridge is available.`);
24
+ }
25
+ const transport = new StdioServerTransport();
26
+ await server.connect(transport);
27
+ console.error('Live MCP Server running on stdio');
28
+ }
29
+ main().catch((error) => {
30
+ console.error('Fatal error:', error);
31
+ process.exit(1);
32
+ });
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { BridgeClient } from '../bridge-client.js';
3
+ export declare function registerCallFunction(server: McpServer, bridge: BridgeClient): void;
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+ export function registerCallFunction(server, bridge) {
3
+ server.registerTool('call_function', {
4
+ title: 'Call LOM Function',
5
+ description: 'Call a function on a Live Object Model object. Args is an optional JSON array string (e.g. "[1, 2]").',
6
+ inputSchema: {
7
+ path: z.string().describe('LOM path (e.g. "live_set tracks 0")'),
8
+ function: z.string().describe('Function name (e.g. "fire", "stop")'),
9
+ args: z
10
+ .string()
11
+ .optional()
12
+ .describe('Arguments as JSON array string (e.g. "[1, \\"hello\\"]")'),
13
+ },
14
+ }, async ({ path, function: fn, args }) => {
15
+ try {
16
+ const parsedArgs = args
17
+ ? JSON.parse(args)
18
+ : undefined;
19
+ const result = await bridge.request('call_function', {
20
+ path: path,
21
+ function: fn,
22
+ args: parsedArgs,
23
+ });
24
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
25
+ }
26
+ catch (error) {
27
+ const message = error instanceof Error ? error.message : String(error);
28
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
29
+ }
30
+ });
31
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { BridgeClient } from '../bridge-client.js';
3
+ export declare function registerGetChildren(server: McpServer, bridge: BridgeClient): void;
@@ -0,0 +1,21 @@
1
+ import { z } from 'zod';
2
+ export function registerGetChildren(server, bridge) {
3
+ server.registerTool('get_children', {
4
+ title: 'Get LOM Children',
5
+ description: 'List the children of a Live Object Model object. Returns child paths for navigation.',
6
+ inputSchema: {
7
+ path: z.string().describe('LOM path (e.g. "live_set", "live_set tracks 0")'),
8
+ },
9
+ }, async ({ path }) => {
10
+ try {
11
+ const result = await bridge.request('get_children', {
12
+ path: path,
13
+ });
14
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
15
+ }
16
+ catch (error) {
17
+ const message = error instanceof Error ? error.message : String(error);
18
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
19
+ }
20
+ });
21
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { BridgeClient } from '../bridge-client.js';
3
+ export declare function registerGetProperty(server: McpServer, bridge: BridgeClient): void;
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+ export function registerGetProperty(server, bridge) {
3
+ server.registerTool('get_property', {
4
+ title: 'Get LOM Property',
5
+ description: 'Read a property from a Live Object Model object. Use LOM paths like "live_set", "live_set tracks 0", "live_set tracks 0 devices 1 parameters 2".',
6
+ inputSchema: {
7
+ path: z.string().describe('LOM path (e.g. "live_set tracks 0")'),
8
+ property: z.string().describe('Property name (e.g. "tempo", "name")'),
9
+ },
10
+ }, async ({ path, property }) => {
11
+ try {
12
+ const result = await bridge.request('get_property', {
13
+ path: path,
14
+ property,
15
+ });
16
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
17
+ }
18
+ catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
21
+ }
22
+ });
23
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { BridgeClient } from '../bridge-client.js';
3
+ export declare function registerAllTools(server: McpServer, bridge: BridgeClient): void;
@@ -0,0 +1,14 @@
1
+ import { registerGetProperty } from './get-property.js';
2
+ import { registerSetProperty } from './set-property.js';
3
+ import { registerCallFunction } from './call-function.js';
4
+ import { registerGetChildren } from './get-children.js';
5
+ import { registerObserve } from './observe.js';
6
+ import { registerUnobserve } from './unobserve.js';
7
+ export function registerAllTools(server, bridge) {
8
+ registerGetProperty(server, bridge);
9
+ registerSetProperty(server, bridge);
10
+ registerCallFunction(server, bridge);
11
+ registerGetChildren(server, bridge);
12
+ registerObserve(server, bridge);
13
+ registerUnobserve(server, bridge);
14
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { BridgeClient } from '../bridge-client.js';
3
+ export declare function registerObserve(server: McpServer, bridge: BridgeClient): void;
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+ export function registerObserve(server, bridge) {
3
+ server.registerTool('observe', {
4
+ title: 'Observe LOM Property',
5
+ description: 'Subscribe to changes on a Live Object Model property. Returns a subscriptionId for later unobserve.',
6
+ inputSchema: {
7
+ path: z.string().describe('LOM path (e.g. "live_set tracks 0")'),
8
+ property: z.string().describe('Property name to observe (e.g. "tempo", "name")'),
9
+ },
10
+ }, async ({ path, property }) => {
11
+ try {
12
+ const result = await bridge.request('observe', {
13
+ path: path,
14
+ property,
15
+ });
16
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
17
+ }
18
+ catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
21
+ }
22
+ });
23
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { BridgeClient } from '../bridge-client.js';
3
+ export declare function registerSetProperty(server: McpServer, bridge: BridgeClient): void;
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ export function registerSetProperty(server, bridge) {
3
+ server.registerTool('set_property', {
4
+ title: 'Set LOM Property',
5
+ description: 'Set a property on a Live Object Model object. The value should be a JSON string that will be parsed (e.g. "120.0", "\\"hello\\"", "true").',
6
+ inputSchema: {
7
+ path: z.string().describe('LOM path (e.g. "live_set tracks 0")'),
8
+ property: z.string().describe('Property name (e.g. "tempo", "name")'),
9
+ value: z.string().describe('Value as JSON string (e.g. "120.0", "\\"hello\\"", "true")'),
10
+ },
11
+ }, async ({ path, property, value }) => {
12
+ try {
13
+ const parsed = JSON.parse(value);
14
+ const result = await bridge.request('set_property', {
15
+ path: path,
16
+ property,
17
+ value: parsed,
18
+ });
19
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
20
+ }
21
+ catch (error) {
22
+ const message = error instanceof Error ? error.message : String(error);
23
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
24
+ }
25
+ });
26
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { BridgeClient } from '../bridge-client.js';
3
+ export declare function registerUnobserve(server: McpServer, bridge: BridgeClient): void;
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod';
2
+ export function registerUnobserve(server, bridge) {
3
+ server.registerTool('unobserve', {
4
+ title: 'Unobserve LOM Property',
5
+ description: 'Unsubscribe from a previously observed Live Object Model property using its subscriptionId.',
6
+ inputSchema: {
7
+ subscriptionId: z.string().describe('Subscription ID returned from observe'),
8
+ },
9
+ }, async ({ subscriptionId }) => {
10
+ try {
11
+ const result = await bridge.request('unobserve', { subscriptionId });
12
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
13
+ }
14
+ catch (error) {
15
+ const message = error instanceof Error ? error.message : String(error);
16
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
17
+ }
18
+ });
19
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@mixofreality/live-mcp",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "bin": {
7
+ "live-mcp": "./dist/index.js"
8
+ },
9
+ "files": ["dist"],
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "node --watch dist/index.js",
13
+ "typecheck": "tsc --noEmit",
14
+ "test": "vitest run",
15
+ "test:watch": "vitest",
16
+ "check": "npm run typecheck && npm run test",
17
+ "prepublishOnly": "npm run check && npm run build"
18
+ },
19
+ "keywords": ["mcp", "ableton", "live", "max4live", "lom"],
20
+ "license": "MIT",
21
+ "description": "MCP server for controlling Ableton Live via the Live Object Model",
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "^1.27.1",
24
+ "zod": "^3.25.0"
25
+ },
26
+ "devDependencies": {
27
+ "typescript": "~5.9.3",
28
+ "vitest": "^4.0.16",
29
+ "@types/node": "^22.0.0",
30
+ "@mixofreality/max4live-nodescript-ts": "file:../../../shared/max4live-nodescript-ts"
31
+ }
32
+ }