Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
1,496 changes: 1,496 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions js/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "sails-js-mcp-server",
"version": "0.5.1",
"description": "MCP server for Sails IDL & Program development",
"private": true,
"type": "module",
"license": "GPL-3.0",
"author": "Gear Technologies",
"bin": {
"sails-mcp": "./src/index.ts"
},
"scripts": {
"start": "bun src/index.ts",
"test": "bun test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"sails-js": "workspace:*",
"sails-js-parser-idl-v2": "workspace:*",
"sails-js-types": "workspace:*",
"sails-js-util": "workspace:*",
"viem": "^2.0.0",
"zod": "^4.0.0"
}
}
149 changes: 149 additions & 0 deletions js/mcp-server/src/idl-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { readFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';

Check failure on line 2 in js/mcp-server/src/idl-loader.ts

View workflow job for this annotation

GitHub Actions / lint

Use default import for module `node:path`

interface IdlSource {
content: string;
id: string;
}

interface IdlLoader {
load(path: string): Promise<IdlSource>;
resolve(basePath: string, includePath: string): string | null;
}

/**
* Loads IDL files from the local filesystem.
* Resolves relative include paths against the directory of the including file.
*/
class FsLoader implements IdlLoader {
async load(path: string): Promise<IdlSource> {
const canonical = resolve(path);
const content = await readFile(canonical, 'utf-8');

Check failure on line 21 in js/mcp-server/src/idl-loader.ts

View workflow job for this annotation

GitHub Actions / lint

Prefer `utf8` over `utf-8`
return { content, id: canonical };
}

resolve(basePath: string, includePath: string): string | null {
if (includePath.startsWith('git://')) return null;
const baseDir = dirname(resolve(basePath));
return resolve(baseDir, includePath);
}
}

/**
* Loads IDL files from git:// URLs via HTTPS.
* Format: git://github.com/user/repo/path/to/file.idl?branch
*/
class GitLoader implements IdlLoader {
async load(path: string): Promise<IdlSource> {
const url = this.toRawUrl(path);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
const content = await response.text();
return { content, id: path };
}

resolve(basePath: string, includePath: string): string | null {
if (includePath.startsWith('git://')) return includePath;
if (!basePath.startsWith('git://')) return null;

// Resolve relative paths within the same git repo
const baseUrl = new URL(basePath.replace('git://', 'https://'));
const baseParts = baseUrl.pathname.split('/');
baseParts.pop(); // remove filename
baseParts.push(includePath);
baseUrl.pathname = baseParts.join('/');
return 'git://' + baseUrl.hostname + baseUrl.pathname + baseUrl.search;
}

private toRawUrl(gitUrl: string): string {
// git://github.com/user/repo/path/to/file.idl?branch
const url = new URL(gitUrl.replace('git://', 'https://'));
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length < 3) {
throw new Error(`Invalid git URL: ${gitUrl}. Expected git://host/user/repo/path?branch`);
}
const [user, repo, ...pathParts] = parts;
const branch = (url.searchParams.get('branch') ?? url.search.slice(1)) || 'main';
const host = url.hostname;

if (host === 'github.com') {
return `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${pathParts.join('/')}`;
}
// Fallback: assume GitLab-style raw URL
return `https://${host}/${user}/${repo}/-/raw/${branch}/${pathParts.join('/')}`;
}
}

const fsLoader = new FsLoader();
const gitLoader = new GitLoader();
const defaultLoaders: IdlLoader[] = [fsLoader, gitLoader];

/**
* Preprocess an IDL file, resolving !@include directives recursively.
* Mirrors the logic in rs/idl-parser-v2/src/preprocess/mod.rs.
*/
export async function preprocessIdl(
path: string,
loaders: IdlLoader[] = defaultLoaders,
): Promise<string> {
const visited = new Set<string>();
const result: string[] = [];
await preprocessRecursive(path, loaders, visited, result);
return result.join('');
}

async function preprocessRecursive(
path: string,
loaders: IdlLoader[],
visited: Set<string>,
out: string[],
): Promise<void> {
// Find a loader that can handle this path
const loader = loaders.find((l) => l.resolve(path, path) !== null);
if (!loader) {
throw new Error(`No loader can handle path: ${path}`);
}

const source = await loader.load(path);

// Deduplication
if (visited.has(source.id)) {
return;
}
visited.add(source.id);

for (const line of source.content.split('\n')) {
const trimmed = line.trim();

if (trimmed.startsWith('!@include:')) {
const rest = trimmed.slice('!@include:'.length).trim();
const includePath = rest.replace(/^["']|["']$/g, '');

Check failure on line 122 in js/mcp-server/src/idl-loader.ts

View workflow job for this annotation

GitHub Actions / lint

Prefer `String#replaceAll()` over `String#replace()`

if (!includePath) {
throw new Error('Invalid include directive');
}

// Find a loader that can resolve this include
let nextPath: string | null = null;
for (const l of loaders) {
nextPath = l.resolve(path, includePath);
if (nextPath !== null) break;
}

if (nextPath === null) {
throw new Error(`No loader can resolve include '${includePath}' from: ${path}`);
}

await preprocessRecursive(nextPath, loaders, visited, out);

// Ensure newline after included content
if (out.length > 0 && !out[out.length - 1].endsWith('\n')) {

Check failure on line 142 in js/mcp-server/src/idl-loader.ts

View workflow job for this annotation

GitHub Actions / lint

Prefer `.at(…)` over `[….length - index]`
out.push('\n');
}
} else {
out.push(line + '\n');
}
}
}
45 changes: 45 additions & 0 deletions js/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env bun
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

import { registerIdlTools, getParser } from './tools/idl-tools.js';
import { registerSourceTools } from './tools/source-tools.js';
import { registerCodecTools } from './tools/codec-tools.js';
import { registerHeaderTools } from './tools/header-tools.js';
import { registerTypeTools } from './tools/type-tools.js';
import { registerUtilTools } from './tools/util-tools.js';
import { registerAbiTools } from './tools/abi-tools.js';
import { registerResources } from './resources.js';

const server = new McpServer(
{
name: 'sails-mcp',
version: '0.5.1',
},
{
instructions:
'Sails IDL & Program development server. Start by parsing an IDL with sails_parse_idl ' +
'or loading from file with sails_load_idl. Then use encoding/decoding tools to work with payloads. ' +
'All encode/decode tools require a program to be registered first. ' +
'Use sails_list_programs to see registered programs.',
},
);

// Register all tool categories
registerIdlTools(server);
registerSourceTools(server);
registerCodecTools(server);
registerHeaderTools(server);
registerTypeTools(server);
registerUtilTools(server);
registerAbiTools(server);
registerResources(server);

// Initialize the WASM parser eagerly so first tool call is fast
getParser().catch((err) => {

Check failure on line 39 in js/mcp-server/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

The catch parameter `err` should be named `error`

Check failure on line 39 in js/mcp-server/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Prefer top-level await over using a promise chain
console.error('Warning: Failed to pre-initialize IDL parser:', err.message);
});

// Start the stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
48 changes: 48 additions & 0 deletions js/mcp-server/src/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { SailsProgram, SailsService } from 'sails-js';

Check failure on line 1 in js/mcp-server/src/registry.ts

View workflow job for this annotation

GitHub Actions / lint

'SailsService' is defined but never used. Allowed unused vars must match /^_/u
import type { IIdlDoc } from 'sails-js-types';

export interface RegisteredProgram {
name: string;
doc: IIdlDoc;
program: SailsProgram;
}

/**
* Session-scoped registry of parsed Sails programs.
* Keyed by program name (from IDL or user-provided).
*/
class ProgramRegistry {
private programs = new Map<string, RegisteredProgram>();

register(name: string, doc: IIdlDoc): RegisteredProgram {
const program = new SailsProgram(doc);
const entry: RegisteredProgram = { name, doc, program };
this.programs.set(name, entry);
return entry;
}

get(name: string): RegisteredProgram | undefined {
return this.programs.get(name);
}

getOrThrow(name: string): RegisteredProgram {
const entry = this.programs.get(name);
if (!entry) {
const available = this.list().map((p) => p.name);
throw new Error(
`Program "${name}" not found. Available: [${available.join(', ')}]. Use sails_parse_idl or sails_load_idl first.`,
);
}
return entry;
}

list(): RegisteredProgram[] {
return Array.from(this.programs.values());

Check failure on line 40 in js/mcp-server/src/registry.ts

View workflow job for this annotation

GitHub Actions / lint

Prefer the spread operator over `Array.from(…)`
}

has(name: string): boolean {
return this.programs.has(name);
}
}

export const registry = new ProgramRegistry();
74 changes: 74 additions & 0 deletions js/mcp-server/src/resources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { readFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';

Check failure on line 3 in js/mcp-server/src/resources.ts

View workflow job for this annotation

GitHub Actions / lint

Use default import for module `node:path`
import { fileURLToPath } from 'node:url';

// Resolve paths relative to the repo root (js/mcp-server -> repo root)
function repoRoot(): string {
const thisDir = dirname(fileURLToPath(import.meta.url));
return resolve(thisDir, '..', '..', '..');
}

async function readRepoFile(relativePath: string): Promise<string> {
const fullPath = resolve(repoRoot(), relativePath);
return readFile(fullPath, 'utf-8');

Check failure on line 14 in js/mcp-server/src/resources.ts

View workflow job for this annotation

GitHub Actions / lint

Prefer `utf8` over `utf-8`
}

export function registerResources(server: McpServer) {
server.registerResource(
'sails://specs/header-v1',
'sails://specs/header-v1',
{
description: 'Sails Message Header v1 specification — 16-byte binary header format for routing messages.',
mimeType: 'text/markdown',
},
async () => {
const content = await readRepoFile('docs/sails-header-v1-spec.md');
return { contents: [{ uri: 'sails://specs/header-v1', text: content, mimeType: 'text/markdown' }] };
},
);

server.registerResource(
'sails://specs/idl-v2',
'sails://specs/idl-v2',
{
description: 'Sails IDL v2 language specification — grammar, types, services, programs.',
mimeType: 'text/markdown',
},
async () => {
const content = await readRepoFile('docs/idl-v2-spec.md');
return { contents: [{ uri: 'sails://specs/idl-v2', text: content, mimeType: 'text/markdown' }] };
},
);

server.registerResource(
'sails://specs/interface-id',
'sails://specs/interface-id',
{
description: 'Interface ID specification — how structural BLAKE3 hashes identify services.',
mimeType: 'text/markdown',
},
async () => {
const spec = await readRepoFile('docs/interface-id-spec.md');
const hash = await readRepoFile('docs/reflect-hash-spec.md');
return {
contents: [
{ uri: 'sails://specs/interface-id', text: `${spec}\n\n---\n\n${hash}`, mimeType: 'text/markdown' },
],
};
},
);

server.registerResource(
'sails://examples/demo',
'sails://examples/demo',
{
description: 'Complete demo IDL file showing services, events, types, constructors, and inheritance.',
mimeType: 'text/plain',
},
async () => {
const content = await readRepoFile('examples/demo/client/demo_client.idl');
return { contents: [{ uri: 'sails://examples/demo', text: content, mimeType: 'text/plain' }] };
},
);
}
Loading
Loading