@aerostack/core 0.10.1 → 0.11.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,169 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const workspace_client_1 = require("../workspace-client");
5
+ const mockFetch = vitest_1.vi.fn();
6
+ global.fetch = mockFetch;
7
+ vitest_1.vi.stubGlobal('crypto', { randomUUID: () => 'test-req-uuid' });
8
+ function jsonResponse(body, status = 200, statusText = 'OK') {
9
+ return {
10
+ ok: status >= 200 && status < 300,
11
+ status,
12
+ statusText,
13
+ json: () => Promise.resolve(body),
14
+ headers: new Map(),
15
+ };
16
+ }
17
+ (0, vitest_1.describe)('WorkspaceClient', () => {
18
+ let client;
19
+ (0, vitest_1.beforeEach)(() => {
20
+ vitest_1.vi.clearAllMocks();
21
+ client = new workspace_client_1.WorkspaceClient({ slug: 'test-ws', token: 'mwt_abc123' });
22
+ });
23
+ // ── Constructor ────────────────────────────────────────────────
24
+ (0, vitest_1.it)('should build gateway URL from slug', () => {
25
+ const c = new workspace_client_1.WorkspaceClient({ slug: 'my-ws', token: 'mwt_x' });
26
+ // Verified via fetch call URL
27
+ (0, vitest_1.expect)(c).toBeInstanceOf(workspace_client_1.WorkspaceClient);
28
+ });
29
+ (0, vitest_1.it)('should use custom baseUrl', () => {
30
+ const c = new workspace_client_1.WorkspaceClient({ slug: 'x', token: 'mwt_x', baseUrl: 'https://custom.dev' });
31
+ (0, vitest_1.expect)(c).toBeInstanceOf(workspace_client_1.WorkspaceClient);
32
+ });
33
+ // ── initialize ─────────────────────────────────────────────────
34
+ (0, vitest_1.it)('should send initialize with MCP protocol params', async () => {
35
+ mockFetch.mockResolvedValueOnce(jsonResponse({
36
+ jsonrpc: '2.0', id: 0,
37
+ result: {
38
+ protocolVersion: '2024-11-05',
39
+ capabilities: { tools: { listChanged: false } },
40
+ serverInfo: { name: 'gateway', version: '1.0.0' },
41
+ },
42
+ }));
43
+ const result = await client.initialize();
44
+ (0, vitest_1.expect)(result.protocolVersion).toBe('2024-11-05');
45
+ (0, vitest_1.expect)(result.serverInfo.name).toBe('gateway');
46
+ const [url, opts] = mockFetch.mock.calls[0];
47
+ (0, vitest_1.expect)(url).toBe('https://mcp.aerostack.dev/ws/test-ws');
48
+ const body = JSON.parse(opts.body);
49
+ (0, vitest_1.expect)(body.method).toBe('initialize');
50
+ (0, vitest_1.expect)(body.params.protocolVersion).toBe('2024-11-05');
51
+ (0, vitest_1.expect)(body.params.clientInfo.name).toBe('@aerostack/core');
52
+ (0, vitest_1.expect)(body.params.clientInfo.version).toBe('0.10.0');
53
+ });
54
+ // ── listTools ──────────────────────────────────────────────────
55
+ (0, vitest_1.it)('should list tools', async () => {
56
+ mockFetch.mockResolvedValueOnce(jsonResponse({
57
+ jsonrpc: '2.0', id: 1,
58
+ result: {
59
+ tools: [
60
+ { name: 'github__create_issue', description: 'Create issue' },
61
+ { name: 'linear__create_ticket', description: 'Create ticket' },
62
+ ],
63
+ },
64
+ }));
65
+ const tools = await client.listTools();
66
+ (0, vitest_1.expect)(tools).toHaveLength(2);
67
+ (0, vitest_1.expect)(tools[0].name).toBe('github__create_issue');
68
+ });
69
+ (0, vitest_1.it)('should return empty array when no tools', async () => {
70
+ mockFetch.mockResolvedValueOnce(jsonResponse({
71
+ jsonrpc: '2.0', id: 1, result: {},
72
+ }));
73
+ const tools = await client.listTools();
74
+ (0, vitest_1.expect)(tools).toEqual([]);
75
+ });
76
+ // ── callTool ───────────────────────────────────────────────────
77
+ (0, vitest_1.it)('should call a tool with arguments', async () => {
78
+ mockFetch.mockResolvedValueOnce(jsonResponse({
79
+ jsonrpc: '2.0', id: 2,
80
+ result: { content: [{ type: 'text', text: 'Issue created' }] },
81
+ }));
82
+ const result = await client.callTool('github__create_issue', { title: 'Bug' });
83
+ (0, vitest_1.expect)(result.content?.[0]?.text).toBe('Issue created');
84
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
85
+ (0, vitest_1.expect)(body.params.name).toBe('github__create_issue');
86
+ (0, vitest_1.expect)(body.params.arguments).toEqual({ title: 'Bug' });
87
+ });
88
+ // ── listResources ──────────────────────────────────────────────
89
+ (0, vitest_1.it)('should list resources', async () => {
90
+ mockFetch.mockResolvedValueOnce(jsonResponse({
91
+ jsonrpc: '2.0', id: 3,
92
+ result: { resources: [{ uri: 'github://repos', name: 'Repos' }] },
93
+ }));
94
+ const resources = await client.listResources();
95
+ (0, vitest_1.expect)(resources).toHaveLength(1);
96
+ (0, vitest_1.expect)(resources[0].uri).toBe('github://repos');
97
+ });
98
+ // ── readResource ───────────────────────────────────────────────
99
+ (0, vitest_1.it)('should read a resource', async () => {
100
+ mockFetch.mockResolvedValueOnce(jsonResponse({
101
+ jsonrpc: '2.0', id: 4,
102
+ result: { contents: [{ uri: 'github://repos/main', text: 'content' }] },
103
+ }));
104
+ const result = await client.readResource('github://repos/main');
105
+ (0, vitest_1.expect)(result.contents[0].text).toBe('content');
106
+ });
107
+ // ── ping ───────────────────────────────────────────────────────
108
+ (0, vitest_1.it)('should ping successfully', async () => {
109
+ mockFetch.mockResolvedValueOnce(jsonResponse({ jsonrpc: '2.0', id: 5, result: {} }));
110
+ await (0, vitest_1.expect)(client.ping()).resolves.toBeUndefined();
111
+ });
112
+ // ── Error handling ─────────────────────────────────────────────
113
+ (0, vitest_1.it)('should throw AerostackError on JSON-RPC error', async () => {
114
+ mockFetch.mockResolvedValueOnce(jsonResponse({
115
+ jsonrpc: '2.0', id: 1,
116
+ error: { code: -32602, message: 'Invalid params' },
117
+ }, 400));
118
+ try {
119
+ await client.listTools();
120
+ vitest_1.expect.unreachable('Should have thrown');
121
+ }
122
+ catch (e) {
123
+ (0, vitest_1.expect)(e).toBeInstanceOf(workspace_client_1.AerostackError);
124
+ const err = e;
125
+ (0, vitest_1.expect)(err.message).toBe('Invalid params');
126
+ (0, vitest_1.expect)(err.statusCode).toBe(400);
127
+ (0, vitest_1.expect)(err.rpcCode).toBe(-32602);
128
+ (0, vitest_1.expect)(err.requestId).toBe('test-req-uuid');
129
+ }
130
+ });
131
+ (0, vitest_1.it)('should throw AerostackError on non-JSON response', async () => {
132
+ mockFetch.mockResolvedValueOnce({
133
+ ok: false, status: 502, statusText: 'Bad Gateway',
134
+ json: () => Promise.reject(new SyntaxError('Unexpected token')),
135
+ });
136
+ try {
137
+ await client.listTools();
138
+ vitest_1.expect.unreachable('Should have thrown');
139
+ }
140
+ catch (e) {
141
+ (0, vitest_1.expect)(e).toBeInstanceOf(workspace_client_1.AerostackError);
142
+ const err = e;
143
+ (0, vitest_1.expect)(err.statusCode).toBe(502);
144
+ (0, vitest_1.expect)(err.rpcCode).toBe(-32700);
145
+ (0, vitest_1.expect)(err.message).toContain('Non-JSON');
146
+ }
147
+ });
148
+ (0, vitest_1.it)('should throw AerostackError on HTTP error without JSON-RPC error body', async () => {
149
+ mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'rate limited' }, // valid JSON but no .error field
150
+ 429, 'Too Many Requests'));
151
+ try {
152
+ await client.listTools();
153
+ vitest_1.expect.unreachable('Should have thrown');
154
+ }
155
+ catch (e) {
156
+ (0, vitest_1.expect)(e).toBeInstanceOf(workspace_client_1.AerostackError);
157
+ const err = e;
158
+ (0, vitest_1.expect)(err.statusCode).toBe(429);
159
+ (0, vitest_1.expect)(err.message).toContain('429');
160
+ }
161
+ });
162
+ // ── Auth header ────────────────────────────────────────────────
163
+ (0, vitest_1.it)('should send Bearer token in Authorization header', async () => {
164
+ mockFetch.mockResolvedValueOnce(jsonResponse({ jsonrpc: '2.0', id: 0, result: {} }));
165
+ await client.ping();
166
+ const headers = mockFetch.mock.calls[0][1].headers;
167
+ (0, vitest_1.expect)(headers['Authorization']).toBe('Bearer mwt_abc123');
168
+ });
169
+ });
package/dist/client.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AerostackClient = exports.AerocallClient = void 0;
4
+ const workspace_client_1 = require("./workspace-client");
4
5
  /** @deprecated Use AerostackClient instead. */
5
6
  class AerocallClient {
6
7
  constructor(config) {
@@ -23,14 +24,18 @@ class AerocallClient {
23
24
  return headers;
24
25
  }
25
26
  async request(path, method, body, isMultipart = false) {
27
+ const headers = this.getHeaders(isMultipart);
28
+ const requestId = headers['X-Request-ID'] ?? '';
26
29
  const response = await fetch(`${this.baseUrl}${path}`, {
27
30
  method,
28
- headers: this.getHeaders(isMultipart),
31
+ headers,
29
32
  body: isMultipart ? body : (body ? JSON.stringify(body) : undefined)
30
33
  });
31
34
  if (!response.ok) {
32
35
  const err = await response.json().catch(() => ({ error: { message: response.statusText } }));
33
- throw new Error(err.error?.message || 'Unknown error');
36
+ const message = err.error?.message || 'Unknown error';
37
+ const code = err.error?.code;
38
+ throw new workspace_client_1.AerostackError(message, response.status, code ?? response.status, requestId);
34
39
  }
35
40
  return response.json();
36
41
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { AerocallClient, AerocallConfig, AerostackClient, AerostackConfig } from './client';
2
+ export { WorkspaceClient, AerostackError } from './workspace-client';
3
+ export type { WorkspaceClientConfig, McpTool, McpResource, McpToolResult, McpResourceContent } from './workspace-client';
2
4
  export * from './types';
3
5
  export * from './types/gateway';
package/dist/index.js CHANGED
@@ -14,9 +14,12 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.AerostackClient = exports.AerocallClient = void 0;
17
+ exports.AerostackError = exports.WorkspaceClient = exports.AerostackClient = exports.AerocallClient = void 0;
18
18
  var client_1 = require("./client");
19
19
  Object.defineProperty(exports, "AerocallClient", { enumerable: true, get: function () { return client_1.AerocallClient; } });
20
20
  Object.defineProperty(exports, "AerostackClient", { enumerable: true, get: function () { return client_1.AerostackClient; } });
21
+ var workspace_client_1 = require("./workspace-client");
22
+ Object.defineProperty(exports, "WorkspaceClient", { enumerable: true, get: function () { return workspace_client_1.WorkspaceClient; } });
23
+ Object.defineProperty(exports, "AerostackError", { enumerable: true, get: function () { return workspace_client_1.AerostackError; } });
21
24
  __exportStar(require("./types"), exports);
22
25
  __exportStar(require("./types/gateway"), exports);
@@ -0,0 +1,95 @@
1
+ /**
2
+ * WorkspaceClient — typed client for Aerostack MCP Workspace Gateway.
3
+ *
4
+ * Wraps the JSON-RPC 2.0 protocol for MCP workspace tool discovery and invocation.
5
+ * Uses workspace tokens (mwt_) for auth, separate from project API keys.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * const ws = new WorkspaceClient({
10
+ * slug: 'my-workspace',
11
+ * token: 'mwt_...',
12
+ * });
13
+ *
14
+ * const tools = await ws.listTools();
15
+ * const result = await ws.callTool('github__create_issue', { title: 'Bug fix' });
16
+ * ```
17
+ */
18
+ export interface WorkspaceClientConfig {
19
+ /** Workspace slug (used in gateway URL) */
20
+ slug: string;
21
+ /** Workspace token (mwt_...) */
22
+ token: string;
23
+ /** Override gateway base URL (default: https://mcp.aerostack.dev) */
24
+ baseUrl?: string;
25
+ }
26
+ export interface McpTool {
27
+ name: string;
28
+ description?: string;
29
+ inputSchema?: Record<string, unknown>;
30
+ }
31
+ export interface McpResource {
32
+ uri: string;
33
+ name: string;
34
+ description?: string;
35
+ mimeType?: string;
36
+ }
37
+ export interface McpToolResult {
38
+ content?: Array<{
39
+ type: string;
40
+ text?: string;
41
+ data?: string;
42
+ mimeType?: string;
43
+ }>;
44
+ isError?: boolean;
45
+ [key: string]: unknown;
46
+ }
47
+ export interface McpResourceContent {
48
+ contents: Array<{
49
+ uri: string;
50
+ text?: string;
51
+ blob?: string;
52
+ mimeType?: string;
53
+ }>;
54
+ }
55
+ export declare class AerostackError extends Error {
56
+ readonly statusCode: number;
57
+ readonly rpcCode: number;
58
+ readonly requestId: string;
59
+ constructor(message: string, statusCode: number, rpcCode: number, requestId: string);
60
+ }
61
+ export declare class WorkspaceClient {
62
+ private readonly gatewayUrl;
63
+ private readonly token;
64
+ private rpcId;
65
+ constructor(config: WorkspaceClientConfig);
66
+ /** Send a JSON-RPC 2.0 request to the workspace gateway. */
67
+ private rpc;
68
+ /** Initialize the MCP session. Sends required protocol fields per MCP spec. */
69
+ initialize(): Promise<{
70
+ protocolVersion: string;
71
+ capabilities: Record<string, unknown>;
72
+ serverInfo: {
73
+ name: string;
74
+ version: string;
75
+ };
76
+ instructions?: string;
77
+ }>;
78
+ /** List all tools available in this workspace (namespaced as server__tool). */
79
+ listTools(): Promise<McpTool[]>;
80
+ /**
81
+ * Call a tool by its namespaced name.
82
+ * @param name - Tool name in "server__tool" format (e.g. "github__create_issue")
83
+ * @param args - Tool arguments matching the tool's inputSchema
84
+ */
85
+ callTool(name: string, args?: Record<string, unknown>): Promise<McpToolResult>;
86
+ /** List all resources available across workspace servers. */
87
+ listResources(): Promise<McpResource[]>;
88
+ /**
89
+ * Read a specific resource by its namespaced URI.
90
+ * @param uri - Resource URI in "serverSlug://originalUri" format
91
+ */
92
+ readResource(uri: string): Promise<McpResourceContent>;
93
+ /** Ping the workspace gateway (health check). */
94
+ ping(): Promise<void>;
95
+ }
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ /**
3
+ * WorkspaceClient — typed client for Aerostack MCP Workspace Gateway.
4
+ *
5
+ * Wraps the JSON-RPC 2.0 protocol for MCP workspace tool discovery and invocation.
6
+ * Uses workspace tokens (mwt_) for auth, separate from project API keys.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * const ws = new WorkspaceClient({
11
+ * slug: 'my-workspace',
12
+ * token: 'mwt_...',
13
+ * });
14
+ *
15
+ * const tools = await ws.listTools();
16
+ * const result = await ws.callTool('github__create_issue', { title: 'Bug fix' });
17
+ * ```
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.WorkspaceClient = exports.AerostackError = void 0;
21
+ class AerostackError extends Error {
22
+ constructor(message, statusCode, rpcCode, requestId) {
23
+ super(message);
24
+ this.name = 'AerostackError';
25
+ this.statusCode = statusCode;
26
+ this.rpcCode = rpcCode;
27
+ this.requestId = requestId;
28
+ }
29
+ }
30
+ exports.AerostackError = AerostackError;
31
+ class WorkspaceClient {
32
+ constructor(config) {
33
+ this.rpcId = 0;
34
+ const base = (config.baseUrl ?? 'https://mcp.aerostack.dev').replace(/\/$/, '');
35
+ this.gatewayUrl = `${base}/ws/${config.slug}`;
36
+ this.token = config.token;
37
+ }
38
+ /** Send a JSON-RPC 2.0 request to the workspace gateway. */
39
+ async rpc(method, params = {}) {
40
+ const id = this.rpcId++;
41
+ const requestId = typeof crypto !== 'undefined' && crypto.randomUUID
42
+ ? crypto.randomUUID()
43
+ : `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
44
+ const res = await fetch(this.gatewayUrl, {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/json',
48
+ 'Authorization': `Bearer ${this.token}`,
49
+ 'X-Request-ID': requestId,
50
+ },
51
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
52
+ });
53
+ // Guard against non-JSON responses (502, 503, rate-limit HTML, etc.)
54
+ let data;
55
+ try {
56
+ data = await res.json();
57
+ }
58
+ catch {
59
+ throw new AerostackError(`Non-JSON response from gateway (HTTP ${res.status})`, res.status, -32700, requestId);
60
+ }
61
+ // Handle HTTP errors that don't include a JSON-RPC error body
62
+ if (!res.ok && !data.error) {
63
+ throw new AerostackError(`HTTP ${res.status}: ${res.statusText}`, res.status, -32603, requestId);
64
+ }
65
+ if (data.error) {
66
+ throw new AerostackError(data.error.message, res.status, data.error.code, requestId);
67
+ }
68
+ return data.result;
69
+ }
70
+ /** Initialize the MCP session. Sends required protocol fields per MCP spec. */
71
+ async initialize() {
72
+ return this.rpc('initialize', {
73
+ protocolVersion: '2024-11-05',
74
+ clientInfo: { name: '@aerostack/core', version: '0.10.0' },
75
+ capabilities: {},
76
+ });
77
+ }
78
+ /** List all tools available in this workspace (namespaced as server__tool). */
79
+ async listTools() {
80
+ const result = await this.rpc('tools/list');
81
+ return result.tools ?? [];
82
+ }
83
+ /**
84
+ * Call a tool by its namespaced name.
85
+ * @param name - Tool name in "server__tool" format (e.g. "github__create_issue")
86
+ * @param args - Tool arguments matching the tool's inputSchema
87
+ */
88
+ async callTool(name, args = {}) {
89
+ return this.rpc('tools/call', { name, arguments: args });
90
+ }
91
+ /** List all resources available across workspace servers. */
92
+ async listResources() {
93
+ const result = await this.rpc('resources/list');
94
+ return result.resources ?? [];
95
+ }
96
+ /**
97
+ * Read a specific resource by its namespaced URI.
98
+ * @param uri - Resource URI in "serverSlug://originalUri" format
99
+ */
100
+ async readResource(uri) {
101
+ return this.rpc('resources/read', { uri });
102
+ }
103
+ /** Ping the workspace gateway (health check). */
104
+ async ping() {
105
+ await this.rpc('ping');
106
+ }
107
+ }
108
+ exports.WorkspaceClient = WorkspaceClient;
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@aerostack/core",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Shared types and RPC client for Aerostack SDKs",
5
5
  "sideEffects": false,
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "scripts": {
9
9
  "build": "tsc",
10
+ "test": "vitest run",
10
11
  "prepublishOnly": "npm run build"
11
12
  },
12
13
  "files": [
@@ -0,0 +1,203 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { WorkspaceClient, AerostackError } from '../workspace-client';
3
+
4
+ const mockFetch = vi.fn();
5
+ global.fetch = mockFetch;
6
+ vi.stubGlobal('crypto', { randomUUID: () => 'test-req-uuid' });
7
+
8
+ function jsonResponse(body: unknown, status = 200, statusText = 'OK') {
9
+ return {
10
+ ok: status >= 200 && status < 300,
11
+ status,
12
+ statusText,
13
+ json: () => Promise.resolve(body),
14
+ headers: new Map(),
15
+ };
16
+ }
17
+
18
+ describe('WorkspaceClient', () => {
19
+ let client: WorkspaceClient;
20
+
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+ client = new WorkspaceClient({ slug: 'test-ws', token: 'mwt_abc123' });
24
+ });
25
+
26
+ // ── Constructor ────────────────────────────────────────────────
27
+
28
+ it('should build gateway URL from slug', () => {
29
+ const c = new WorkspaceClient({ slug: 'my-ws', token: 'mwt_x' });
30
+ // Verified via fetch call URL
31
+ expect(c).toBeInstanceOf(WorkspaceClient);
32
+ });
33
+
34
+ it('should use custom baseUrl', () => {
35
+ const c = new WorkspaceClient({ slug: 'x', token: 'mwt_x', baseUrl: 'https://custom.dev' });
36
+ expect(c).toBeInstanceOf(WorkspaceClient);
37
+ });
38
+
39
+ // ── initialize ─────────────────────────────────────────────────
40
+
41
+ it('should send initialize with MCP protocol params', async () => {
42
+ mockFetch.mockResolvedValueOnce(jsonResponse({
43
+ jsonrpc: '2.0', id: 0,
44
+ result: {
45
+ protocolVersion: '2024-11-05',
46
+ capabilities: { tools: { listChanged: false } },
47
+ serverInfo: { name: 'gateway', version: '1.0.0' },
48
+ },
49
+ }));
50
+
51
+ const result = await client.initialize();
52
+ expect(result.protocolVersion).toBe('2024-11-05');
53
+ expect(result.serverInfo.name).toBe('gateway');
54
+
55
+ const [url, opts] = mockFetch.mock.calls[0];
56
+ expect(url).toBe('https://mcp.aerostack.dev/ws/test-ws');
57
+ const body = JSON.parse(opts.body);
58
+ expect(body.method).toBe('initialize');
59
+ expect(body.params.protocolVersion).toBe('2024-11-05');
60
+ expect(body.params.clientInfo.name).toBe('@aerostack/core');
61
+ expect(body.params.clientInfo.version).toBe('0.10.0');
62
+ });
63
+
64
+ // ── listTools ──────────────────────────────────────────────────
65
+
66
+ it('should list tools', async () => {
67
+ mockFetch.mockResolvedValueOnce(jsonResponse({
68
+ jsonrpc: '2.0', id: 1,
69
+ result: {
70
+ tools: [
71
+ { name: 'github__create_issue', description: 'Create issue' },
72
+ { name: 'linear__create_ticket', description: 'Create ticket' },
73
+ ],
74
+ },
75
+ }));
76
+
77
+ const tools = await client.listTools();
78
+ expect(tools).toHaveLength(2);
79
+ expect(tools[0].name).toBe('github__create_issue');
80
+ });
81
+
82
+ it('should return empty array when no tools', async () => {
83
+ mockFetch.mockResolvedValueOnce(jsonResponse({
84
+ jsonrpc: '2.0', id: 1, result: {},
85
+ }));
86
+ const tools = await client.listTools();
87
+ expect(tools).toEqual([]);
88
+ });
89
+
90
+ // ── callTool ───────────────────────────────────────────────────
91
+
92
+ it('should call a tool with arguments', async () => {
93
+ mockFetch.mockResolvedValueOnce(jsonResponse({
94
+ jsonrpc: '2.0', id: 2,
95
+ result: { content: [{ type: 'text', text: 'Issue created' }] },
96
+ }));
97
+
98
+ const result = await client.callTool('github__create_issue', { title: 'Bug' });
99
+ expect(result.content?.[0]?.text).toBe('Issue created');
100
+
101
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
102
+ expect(body.params.name).toBe('github__create_issue');
103
+ expect(body.params.arguments).toEqual({ title: 'Bug' });
104
+ });
105
+
106
+ // ── listResources ──────────────────────────────────────────────
107
+
108
+ it('should list resources', async () => {
109
+ mockFetch.mockResolvedValueOnce(jsonResponse({
110
+ jsonrpc: '2.0', id: 3,
111
+ result: { resources: [{ uri: 'github://repos', name: 'Repos' }] },
112
+ }));
113
+
114
+ const resources = await client.listResources();
115
+ expect(resources).toHaveLength(1);
116
+ expect(resources[0].uri).toBe('github://repos');
117
+ });
118
+
119
+ // ── readResource ───────────────────────────────────────────────
120
+
121
+ it('should read a resource', async () => {
122
+ mockFetch.mockResolvedValueOnce(jsonResponse({
123
+ jsonrpc: '2.0', id: 4,
124
+ result: { contents: [{ uri: 'github://repos/main', text: 'content' }] },
125
+ }));
126
+
127
+ const result = await client.readResource('github://repos/main');
128
+ expect(result.contents[0].text).toBe('content');
129
+ });
130
+
131
+ // ── ping ───────────────────────────────────────────────────────
132
+
133
+ it('should ping successfully', async () => {
134
+ mockFetch.mockResolvedValueOnce(jsonResponse({ jsonrpc: '2.0', id: 5, result: {} }));
135
+ await expect(client.ping()).resolves.toBeUndefined();
136
+ });
137
+
138
+ // ── Error handling ─────────────────────────────────────────────
139
+
140
+ it('should throw AerostackError on JSON-RPC error', async () => {
141
+ mockFetch.mockResolvedValueOnce(jsonResponse({
142
+ jsonrpc: '2.0', id: 1,
143
+ error: { code: -32602, message: 'Invalid params' },
144
+ }, 400));
145
+
146
+ try {
147
+ await client.listTools();
148
+ expect.unreachable('Should have thrown');
149
+ } catch (e) {
150
+ expect(e).toBeInstanceOf(AerostackError);
151
+ const err = e as AerostackError;
152
+ expect(err.message).toBe('Invalid params');
153
+ expect(err.statusCode).toBe(400);
154
+ expect(err.rpcCode).toBe(-32602);
155
+ expect(err.requestId).toBe('test-req-uuid');
156
+ }
157
+ });
158
+
159
+ it('should throw AerostackError on non-JSON response', async () => {
160
+ mockFetch.mockResolvedValueOnce({
161
+ ok: false, status: 502, statusText: 'Bad Gateway',
162
+ json: () => Promise.reject(new SyntaxError('Unexpected token')),
163
+ });
164
+
165
+ try {
166
+ await client.listTools();
167
+ expect.unreachable('Should have thrown');
168
+ } catch (e) {
169
+ expect(e).toBeInstanceOf(AerostackError);
170
+ const err = e as AerostackError;
171
+ expect(err.statusCode).toBe(502);
172
+ expect(err.rpcCode).toBe(-32700);
173
+ expect(err.message).toContain('Non-JSON');
174
+ }
175
+ });
176
+
177
+ it('should throw AerostackError on HTTP error without JSON-RPC error body', async () => {
178
+ mockFetch.mockResolvedValueOnce(jsonResponse(
179
+ { message: 'rate limited' }, // valid JSON but no .error field
180
+ 429, 'Too Many Requests',
181
+ ));
182
+
183
+ try {
184
+ await client.listTools();
185
+ expect.unreachable('Should have thrown');
186
+ } catch (e) {
187
+ expect(e).toBeInstanceOf(AerostackError);
188
+ const err = e as AerostackError;
189
+ expect(err.statusCode).toBe(429);
190
+ expect(err.message).toContain('429');
191
+ }
192
+ });
193
+
194
+ // ── Auth header ────────────────────────────────────────────────
195
+
196
+ it('should send Bearer token in Authorization header', async () => {
197
+ mockFetch.mockResolvedValueOnce(jsonResponse({ jsonrpc: '2.0', id: 0, result: {} }));
198
+ await client.ping();
199
+
200
+ const headers = mockFetch.mock.calls[0][1].headers;
201
+ expect(headers['Authorization']).toBe('Bearer mwt_abc123');
202
+ });
203
+ });
package/src/client.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AerostackRPC, CollectionItem, CacheListResult, CacheSetEntry, StorageListResult, JobRecord } from './types';
2
+ import { AerostackError } from './workspace-client';
2
3
 
3
4
  /** @deprecated Use AerostackConfig instead. */
4
5
  export interface AerocallConfig {
@@ -35,15 +36,19 @@ export class AerocallClient implements AerostackRPC {
35
36
  }
36
37
 
37
38
  private async request<T>(path: string, method: string, body?: any, isMultipart = false): Promise<T> {
39
+ const headers = this.getHeaders(isMultipart);
40
+ const requestId = headers['X-Request-ID'] ?? '';
38
41
  const response = await fetch(`${this.baseUrl}${path}`, {
39
42
  method,
40
- headers: this.getHeaders(isMultipart),
43
+ headers,
41
44
  body: isMultipart ? body : (body ? JSON.stringify(body) : undefined)
42
45
  });
43
46
 
44
47
  if (!response.ok) {
45
48
  const err = await response.json().catch(() => ({ error: { message: response.statusText } }));
46
- throw new Error((err as any).error?.message || 'Unknown error');
49
+ const message = (err as any).error?.message || 'Unknown error';
50
+ const code = (err as any).error?.code;
51
+ throw new AerostackError(message, response.status, code ?? response.status, requestId);
47
52
  }
48
53
  return response.json() as Promise<T>;
49
54
  }
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { AerocallClient, AerocallConfig, AerostackClient, AerostackConfig } from './client';
2
+ export { WorkspaceClient, AerostackError } from './workspace-client';
3
+ export type { WorkspaceClientConfig, McpTool, McpResource, McpToolResult, McpResourceContent } from './workspace-client';
2
4
  export * from './types';
3
5
  export * from './types/gateway';
@@ -0,0 +1,175 @@
1
+ /**
2
+ * WorkspaceClient — typed client for Aerostack MCP Workspace Gateway.
3
+ *
4
+ * Wraps the JSON-RPC 2.0 protocol for MCP workspace tool discovery and invocation.
5
+ * Uses workspace tokens (mwt_) for auth, separate from project API keys.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * const ws = new WorkspaceClient({
10
+ * slug: 'my-workspace',
11
+ * token: 'mwt_...',
12
+ * });
13
+ *
14
+ * const tools = await ws.listTools();
15
+ * const result = await ws.callTool('github__create_issue', { title: 'Bug fix' });
16
+ * ```
17
+ */
18
+
19
+ export interface WorkspaceClientConfig {
20
+ /** Workspace slug (used in gateway URL) */
21
+ slug: string;
22
+ /** Workspace token (mwt_...) */
23
+ token: string;
24
+ /** Override gateway base URL (default: https://mcp.aerostack.dev) */
25
+ baseUrl?: string;
26
+ }
27
+
28
+ export interface McpTool {
29
+ name: string;
30
+ description?: string;
31
+ inputSchema?: Record<string, unknown>;
32
+ }
33
+
34
+ export interface McpResource {
35
+ uri: string;
36
+ name: string;
37
+ description?: string;
38
+ mimeType?: string;
39
+ }
40
+
41
+ export interface McpToolResult {
42
+ content?: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
43
+ isError?: boolean;
44
+ [key: string]: unknown;
45
+ }
46
+
47
+ export interface McpResourceContent {
48
+ contents: Array<{ uri: string; text?: string; blob?: string; mimeType?: string }>;
49
+ }
50
+
51
+ export class AerostackError extends Error {
52
+ public readonly statusCode: number;
53
+ public readonly rpcCode: number;
54
+ public readonly requestId: string;
55
+
56
+ constructor(message: string, statusCode: number, rpcCode: number, requestId: string) {
57
+ super(message);
58
+ this.name = 'AerostackError';
59
+ this.statusCode = statusCode;
60
+ this.rpcCode = rpcCode;
61
+ this.requestId = requestId;
62
+ }
63
+ }
64
+
65
+ export class WorkspaceClient {
66
+ private readonly gatewayUrl: string;
67
+ private readonly token: string;
68
+ private rpcId = 0;
69
+
70
+ constructor(config: WorkspaceClientConfig) {
71
+ const base = (config.baseUrl ?? 'https://mcp.aerostack.dev').replace(/\/$/, '');
72
+ this.gatewayUrl = `${base}/ws/${config.slug}`;
73
+ this.token = config.token;
74
+ }
75
+
76
+ /** Send a JSON-RPC 2.0 request to the workspace gateway. */
77
+ private async rpc<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
78
+ const id = this.rpcId++;
79
+ const requestId = typeof crypto !== 'undefined' && crypto.randomUUID
80
+ ? crypto.randomUUID()
81
+ : `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
82
+
83
+ const res = await fetch(this.gatewayUrl, {
84
+ method: 'POST',
85
+ headers: {
86
+ 'Content-Type': 'application/json',
87
+ 'Authorization': `Bearer ${this.token}`,
88
+ 'X-Request-ID': requestId,
89
+ },
90
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
91
+ });
92
+
93
+ // Guard against non-JSON responses (502, 503, rate-limit HTML, etc.)
94
+ let data: { jsonrpc: string; id: number; result?: T; error?: { code: number; message: string } };
95
+ try {
96
+ data = await res.json() as typeof data;
97
+ } catch {
98
+ throw new AerostackError(
99
+ `Non-JSON response from gateway (HTTP ${res.status})`,
100
+ res.status,
101
+ -32700,
102
+ requestId,
103
+ );
104
+ }
105
+
106
+ // Handle HTTP errors that don't include a JSON-RPC error body
107
+ if (!res.ok && !data.error) {
108
+ throw new AerostackError(
109
+ `HTTP ${res.status}: ${res.statusText}`,
110
+ res.status,
111
+ -32603,
112
+ requestId,
113
+ );
114
+ }
115
+
116
+ if (data.error) {
117
+ throw new AerostackError(
118
+ data.error.message,
119
+ res.status,
120
+ data.error.code,
121
+ requestId,
122
+ );
123
+ }
124
+
125
+ return data.result as T;
126
+ }
127
+
128
+ /** Initialize the MCP session. Sends required protocol fields per MCP spec. */
129
+ async initialize(): Promise<{
130
+ protocolVersion: string;
131
+ capabilities: Record<string, unknown>;
132
+ serverInfo: { name: string; version: string };
133
+ instructions?: string;
134
+ }> {
135
+ return this.rpc('initialize', {
136
+ protocolVersion: '2024-11-05',
137
+ clientInfo: { name: '@aerostack/core', version: '0.10.0' },
138
+ capabilities: {},
139
+ });
140
+ }
141
+
142
+ /** List all tools available in this workspace (namespaced as server__tool). */
143
+ async listTools(): Promise<McpTool[]> {
144
+ const result = await this.rpc<{ tools: McpTool[] }>('tools/list');
145
+ return result.tools ?? [];
146
+ }
147
+
148
+ /**
149
+ * Call a tool by its namespaced name.
150
+ * @param name - Tool name in "server__tool" format (e.g. "github__create_issue")
151
+ * @param args - Tool arguments matching the tool's inputSchema
152
+ */
153
+ async callTool(name: string, args: Record<string, unknown> = {}): Promise<McpToolResult> {
154
+ return this.rpc<McpToolResult>('tools/call', { name, arguments: args });
155
+ }
156
+
157
+ /** List all resources available across workspace servers. */
158
+ async listResources(): Promise<McpResource[]> {
159
+ const result = await this.rpc<{ resources: McpResource[] }>('resources/list');
160
+ return result.resources ?? [];
161
+ }
162
+
163
+ /**
164
+ * Read a specific resource by its namespaced URI.
165
+ * @param uri - Resource URI in "serverSlug://originalUri" format
166
+ */
167
+ async readResource(uri: string): Promise<McpResourceContent> {
168
+ return this.rpc<McpResourceContent>('resources/read', { uri });
169
+ }
170
+
171
+ /** Ping the workspace gateway (health check). */
172
+ async ping(): Promise<void> {
173
+ await this.rpc('ping');
174
+ }
175
+ }