@taiga-ai/mcp-server 0.14.0-rc.426 → 0.16.0-dev.430

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ai/mcp-server",
3
- "version": "0.14.0-rc.426",
3
+ "version": "0.16.0-dev.430",
4
4
  "description": "MCP server for Taiga -- gives AI assistants access to documents and products",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -13,6 +13,9 @@
13
13
  "default": "./dist/index.js"
14
14
  }
15
15
  },
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
16
19
  "scripts": {
17
20
  "build": "tsc --noEmit && node scripts/build.mjs",
18
21
  "typecheck": "tsc --noEmit",
@@ -22,10 +25,11 @@
22
25
  "test": "vitest run"
23
26
  },
24
27
  "dependencies": {
25
- "@modelcontextprotocol/sdk": "^1.30.0",
28
+ "@modelcontextprotocol/server": "^2.3.1",
26
29
  "zod": "^4.4.3"
27
30
  },
28
31
  "devDependencies": {
32
+ "@modelcontextprotocol/client": "^2.3.1",
29
33
  "@tsconfig/strictest": "^2.0.8",
30
34
  "@types/mdast": "^4.0.4",
31
35
  "@types/node": "^22.20.1",
package/scripts/build.mjs CHANGED
@@ -9,10 +9,10 @@ const SHEBANG = '#!/usr/bin/env node';
9
9
  *
10
10
  * The published npm artifact must not reference private workspace packages, so
11
11
  * `@taiga/*` (notably `@taiga/feature-flags`, a runtime value) is inlined. The
12
- * genuinely-published deps — the MCP SDK and zod — stay external and are
12
+ * genuinely-published deps — the MCP server SDK and zod — stay external and are
13
13
  * installed normally by consumers. Node built-ins are external via platform.
14
14
  */
15
- const external = [/^@modelcontextprotocol\/sdk(\/|$)/, /^zod(\/|$)/, /^node:/];
15
+ const external = [/^@modelcontextprotocol\/server(\/|$)/, /^zod(\/|$)/, /^node:/];
16
16
 
17
17
  const bundle = await rolldown({
18
18
  input: 'src/index.ts',
package/src/client.ts CHANGED
@@ -45,6 +45,8 @@ export class TaigaApiError extends Error {
45
45
  readonly status: number;
46
46
  readonly code: string | undefined;
47
47
  readonly apiMessage: string | undefined;
48
+ /** A refusal's structured reason, when the API sent one: `{ reason, ... }` (a 409's discriminator and its data). */
49
+ readonly details: Record<string, unknown> | undefined;
48
50
 
49
51
  constructor(method: string, path: string, status: number, body: string) {
50
52
  const parsed = parseErrorBody(body);
@@ -53,6 +55,7 @@ export class TaigaApiError extends Error {
53
55
  this.status = status;
54
56
  this.code = parsed.code;
55
57
  this.apiMessage = parsed.message;
58
+ this.details = parsed.details;
56
59
  }
57
60
  }
58
61
 
@@ -84,7 +87,7 @@ function query(params: Record<string, string | number | boolean | null | undefin
84
87
  return text ? `?${text}` : '';
85
88
  }
86
89
 
87
- function parseErrorBody(body: string): { code?: string; message?: string } {
90
+ function parseErrorBody(body: string): { code?: string; message?: string; details?: Record<string, unknown> } {
88
91
  try {
89
92
  const json = JSON.parse(body) as { code?: unknown; message?: unknown; details?: unknown };
90
93
  const message = typeof json.message === 'string' ? json.message : undefined;
@@ -97,9 +100,16 @@ function parseErrorBody(body: string): { code?: string; message?: string } {
97
100
  )
98
101
  .filter(Boolean)
99
102
  : [];
103
+ // A service refusal names its gate in an object (`reason`, and what it
104
+ // found, such as the documents still missing); the model needs both.
105
+ const structured =
106
+ typeof json.details === 'object' && json.details !== null && !Array.isArray(json.details)
107
+ ? (json.details as Record<string, unknown>)
108
+ : undefined;
100
109
  return {
101
110
  ...(typeof json.code === 'string' ? { code: json.code } : {}),
102
111
  ...(message ? { message: details.length ? `${message}: ${details.join('; ')}` : message } : {}),
112
+ ...(structured ? { details: structured } : {}),
103
113
  };
104
114
  } catch {
105
115
  return {};
@@ -753,7 +763,15 @@ export class TaigaClient {
753
763
 
754
764
  async updateProduct(
755
765
  productId: string,
756
- body: { autoMerge?: boolean | null | undefined; autoBuild?: boolean | undefined }
766
+ body: {
767
+ autoMerge?: boolean | null | undefined;
768
+ autoBuild?: boolean | undefined;
769
+ name?: string | undefined;
770
+ ownsInfrastructure?: boolean | null | undefined;
771
+ ownsDeployment?: boolean | null | undefined;
772
+ deploymentNotes?: string | null | undefined;
773
+ emitDesignStandards?: boolean | null | undefined;
774
+ }
757
775
  ) {
758
776
  return this.request<{ data: unknown }>('PATCH', `/products/${seg(productId)}`, body);
759
777
  }
@@ -777,4 +795,114 @@ export class TaigaClient {
777
795
  const qs = params.toString();
778
796
  return this.request<{ data: unknown[]; nextCursor: string | null }>('GET', `/factories${qs ? `?${qs}` : ''}`);
779
797
  }
798
+
799
+ async createFactory(body: { name: string }) {
800
+ return this.request<{ data: unknown }>('POST', '/factories', body);
801
+ }
802
+
803
+ async updateFactory(factoryId: string, body: { name?: string | undefined }) {
804
+ return this.request<{ data: unknown }>('PATCH', `/factories/${seg(factoryId)}`, body);
805
+ }
806
+
807
+ // ---------------------------------------------------------------------------
808
+ // Setting a product up
809
+ // ---------------------------------------------------------------------------
810
+
811
+ async createProduct(body: {
812
+ name: string;
813
+ factoryId: string;
814
+ projectType?: 'brownfield' | undefined;
815
+ ownsInfrastructure?: boolean | null | undefined;
816
+ ownsDeployment?: boolean | null | undefined;
817
+ deploymentNotes?: string | undefined;
818
+ }) {
819
+ return this.request<{ data: unknown }>('POST', '/products', body);
820
+ }
821
+
822
+ async previewProductMove(productId: string, factoryId: string) {
823
+ return this.request<{ data: unknown }>('GET', `/products/${seg(productId)}/factory/preview${query({ factoryId })}`);
824
+ }
825
+
826
+ async moveProduct(productId: string, body: { factoryId: string; stopLine?: boolean | undefined }) {
827
+ return this.request<Record<string, unknown>>('PUT', `/products/${seg(productId)}/factory`, body);
828
+ }
829
+
830
+ async startImport(productId: string) {
831
+ return this.request<{ data: { jobId: string } }>('POST', `/products/${seg(productId)}/brownfield-import`);
832
+ }
833
+
834
+ async refreshRealityDocuments(productId: string, docTypes?: readonly string[] | undefined) {
835
+ return this.request<{ data: { jobId: string } }>(
836
+ 'POST',
837
+ `/products/${seg(productId)}/documents/refresh-reality`,
838
+ docTypes ? { docTypes } : undefined
839
+ );
840
+ }
841
+
842
+ async completeSetup(productId: string) {
843
+ return this.request<{ data: unknown }>('POST', `/products/${seg(productId)}/setup/complete`);
844
+ }
845
+
846
+ async reopenSetup(productId: string) {
847
+ return this.request<{ data: unknown }>('POST', `/products/${seg(productId)}/setup/reopen`);
848
+ }
849
+
850
+ // ---------------------------------------------------------------------------
851
+ // Repositories
852
+ // ---------------------------------------------------------------------------
853
+
854
+ /** Every connected GitHub account, page by page: a later account must be findable by its id too. */
855
+ async listGithubInstallations() {
856
+ const data: unknown[] = [];
857
+ let cursor: string | undefined;
858
+ do {
859
+ const result = await this.request<{ data: unknown[]; nextCursor: string | null }>(
860
+ 'GET',
861
+ `/github/installations${query({ limit: 100, cursor })}`
862
+ );
863
+ data.push(...result.data);
864
+ cursor = result.nextCursor ?? undefined;
865
+ } while (cursor);
866
+ return { data };
867
+ }
868
+
869
+ async listInstallationRepositories(
870
+ installationId: string,
871
+ opts: { q?: string | undefined; limit?: number | undefined; cursor?: string | undefined }
872
+ ) {
873
+ return this.request<{ data: unknown[]; nextCursor: string | null }>(
874
+ 'GET',
875
+ `/github/installations/${seg(installationId)}/repositories${query({ ...opts })}`
876
+ );
877
+ }
878
+
879
+ async linkRepository(
880
+ productId: string,
881
+ body: { githubRepositoryId: string; replacesRepositoryId?: string | undefined }
882
+ ) {
883
+ return this.request<{ data: unknown }>('POST', `/products/${seg(productId)}/repositories`, body);
884
+ }
885
+
886
+ async updateRepository(
887
+ productId: string,
888
+ repositoryId: string,
889
+ body: { baseBranch?: string | null | undefined; label?: string | null | undefined }
890
+ ) {
891
+ return this.request<{ data: unknown }>(
892
+ 'PATCH',
893
+ `/products/${seg(productId)}/repositories/${seg(repositoryId)}`,
894
+ body
895
+ );
896
+ }
897
+
898
+ async listRepositoryBranches(productId: string, repositoryId: string) {
899
+ return this.request<{ data: unknown[]; truncated: boolean }>(
900
+ 'GET',
901
+ `/products/${seg(productId)}/repositories/${seg(repositoryId)}/branches`
902
+ );
903
+ }
904
+
905
+ async unlinkRepository(productId: string, repositoryId: string) {
906
+ return this.request<null>('DELETE', `/products/${seg(productId)}/repositories/${seg(repositoryId)}`);
907
+ }
780
908
  }
package/src/index.ts CHANGED
@@ -1,12 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
5
4
  import { TaigaClient } from './client.js';
6
5
  import { readConfig } from './config.js';
7
- import { registerAllTools } from './tools/index.js';
8
- import { SERVER_INSTRUCTIONS } from './tools/instructions.js';
9
- import { SERVER_VERSION } from './version.js';
6
+ import { createServer } from './server.js';
10
7
 
11
8
  async function main() {
12
9
  const config = readConfig();
@@ -26,27 +23,20 @@ async function main() {
26
23
  process.exit(1);
27
24
  });
28
25
 
29
- // Create MCP server with dynamic tool registration and instructions
30
- const server = new McpServer(
31
- {
32
- name: 'taiga',
33
- version: SERVER_VERSION,
34
- },
35
- {
36
- instructions: SERVER_INSTRUCTIONS,
37
- }
38
- );
39
-
40
- registerAllTools(server, client);
41
-
42
26
  const toolCount = introspection.capabilities.length;
43
27
  console.error(
44
28
  `Taiga MCP server started (${toolCount} capabilities, user: ${introspection.email}, role: ${introspection.role})`
45
29
  );
46
30
 
47
- // Connect via stdio transport
48
- const transport = new StdioServerTransport();
49
- await server.connect(transport);
31
+ // serveStdio owns the protocol revision: a client that opens with
32
+ // server/discover is served 2026-07-28, one that opens with initialize the
33
+ // 2025 revision it asks for, from the same instance. Tools are registered
34
+ // from the key's capabilities, so a key never sees a tool it cannot use.
35
+ // Errors outside a request (the stream failing, a message that cannot be
36
+ // read) go to stderr, which the client shows in its server log.
37
+ serveStdio(() => createServer(client), {
38
+ onerror: (error) => console.error('Taiga MCP server error:', error),
39
+ });
50
40
  }
51
41
 
52
42
  main().catch((error) => {
@@ -0,0 +1,153 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
3
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
4
+ import { TaigaClient } from './client.js';
5
+ import { createServer } from './server.js';
6
+
7
+ /**
8
+ * The same server over the 2026-07-28 revision. serveStdio picks the
9
+ * revision from the client's opening exchange, so a client pinned to
10
+ * 2026-07-28 opens with server/discover and every call after it carries the
11
+ * modern envelope. server.integration.test.ts covers the tools in depth over
12
+ * the 2025 handshake; this proves the catalogue, a read, a write, the server
13
+ * instructions, and a wait's progress and cancellation on the new revision.
14
+ */
15
+
16
+ const ITEM_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5e';
17
+ const PRODUCT_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5c';
18
+ const JOB_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4b10';
19
+
20
+ let close: (() => Promise<void>) | undefined;
21
+
22
+ const json = (body: unknown, status = 200) =>
23
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
24
+
25
+ async function bootModern(capabilities: string[], routes: Record<string, (url: string, init: RequestInit) => unknown>) {
26
+ vi.stubGlobal(
27
+ 'fetch',
28
+ vi.fn(async (url: string, init: RequestInit = {}) => {
29
+ const { pathname } = new URL(url);
30
+ if (pathname === '/v1/auth/introspect') {
31
+ return json({ data: { userId: 'u', tenantId: 't', email: 'dev@test.com', role: 'member', capabilities } });
32
+ }
33
+ const route = routes[`${init.method ?? 'GET'} ${pathname}`];
34
+ return route ? route(url, init) : json({ code: 'NOT_FOUND', message: 'No route' }, 404);
35
+ })
36
+ );
37
+ const taiga = new TaigaClient({ baseUrl: 'https://api.test.com', apiKey: 'taiga_sk_live_test' });
38
+ await taiga.introspect();
39
+
40
+ const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
41
+ const handle = serveStdio(() => createServer(taiga, { wait: { pollIntervalMs: 5 } }), { transport: serverSide });
42
+ const client = new Client(
43
+ { name: 'modern-test', version: '0.0.1' },
44
+ { versionNegotiation: { mode: { pin: '2026-07-28' } } }
45
+ );
46
+ await client.connect(clientSide);
47
+ close = async () => {
48
+ await client.close();
49
+ await handle.close();
50
+ };
51
+ return { client };
52
+ }
53
+ afterEach(async () => {
54
+ await close?.();
55
+ close = undefined;
56
+ vi.unstubAllGlobals();
57
+ });
58
+
59
+ describe('the server on the 2026-07-28 revision', () => {
60
+ it('negotiates 2026-07-28 and lists the same catalogue, with the server instructions', async () => {
61
+ const { client } = await bootModern(['products:read', 'backlog:read', 'backlog:write', 'products:write'], {});
62
+
63
+ expect(client.getProtocolEra()).toBe('modern');
64
+ expect(client.getInstructions()).toContain('Taiga');
65
+ const names = (await client.listTools()).tools.map((t) => t.name);
66
+ expect(names).toContain('taiga_get_product');
67
+ expect(names).toContain('taiga_request_initiatives');
68
+ });
69
+
70
+ it('reads, writes and refuses an unknown argument as it does on the 2025 revision', async () => {
71
+ let posted: unknown;
72
+ const { client } = await bootModern(['products:read', 'products:write'], {
73
+ [`GET /v1/products/${PRODUCT_ID}`]: () => json({ data: { id: PRODUCT_ID, name: 'Web', slug: 'web' } }),
74
+ 'POST /v1/agent-jobs': (_url, init) => {
75
+ posted = JSON.parse(init.body as string);
76
+ return json({ data: { id: JOB_ID, type: 'generate_backlog', status: 'pending' } });
77
+ },
78
+ });
79
+
80
+ const product = await client.callTool({ name: 'taiga_get_product', arguments: { productId: PRODUCT_ID } });
81
+ const requested = await client.callTool({
82
+ name: 'taiga_request_initiatives',
83
+ arguments: { productId: PRODUCT_ID, title: 'Health endpoint' },
84
+ });
85
+ const stray = await client.callTool({
86
+ name: 'taiga_get_product',
87
+ arguments: { productId: PRODUCT_ID, note: 'not an argument' },
88
+ });
89
+
90
+ expect(JSON.parse((product.content as { text: string }[])[0]!.text)).toMatchObject({ id: PRODUCT_ID, name: 'Web' });
91
+ expect(posted).toMatchObject({ scope: 'product', type: 'generate_backlog', targetId: PRODUCT_ID });
92
+ expect(JSON.parse((requested.content as { text: string }[])[0]!.text)).toMatchObject({ id: JOB_ID });
93
+ expect(stray.isError).toBe(true);
94
+ expect((stray.content as { text: string }[])[0]!.text).toMatch(/Unrecognized key/);
95
+ });
96
+
97
+ it('reports progress while a wait runs, and ends the read in flight when the call is cancelled', async () => {
98
+ let reads = 0;
99
+ let hung: AbortSignal | undefined;
100
+ const job = (status: string) => json({ data: { id: JOB_ID, type: 'refresh_reality', status } });
101
+ const { client } = await bootModern(['products:read', 'backlog:read'], {
102
+ [`GET /v1/agent-jobs/${JOB_ID}`]: () => job(++reads < 3 ? 'processing' : 'completed'),
103
+ [`GET /v1/backlog/${ITEM_ID}`]: (_url, init) => {
104
+ hung = init.signal!;
105
+ return new Promise((_resolve, reject) =>
106
+ init.signal!.addEventListener('abort', () => reject(init.signal!.reason))
107
+ );
108
+ },
109
+ });
110
+ const onprogress = vi.fn();
111
+
112
+ const waited = await client.callTool({ name: 'taiga_wait_for_job', arguments: { jobId: JOB_ID } }, { onprogress });
113
+ expect(JSON.parse((waited.content as { text: string }[])[0]!.text)).toMatchObject({ reached: true });
114
+ expect(onprogress).toHaveBeenCalledWith(expect.objectContaining({ message: 'Still waiting' }));
115
+
116
+ const controller = new AbortController();
117
+ const pending = client.callTool(
118
+ { name: 'taiga_wait_for_backlog_item', arguments: { itemId: ITEM_ID, until: 'planned', timeoutSeconds: 60 } },
119
+ { signal: controller.signal }
120
+ );
121
+ await vi.waitFor(() => expect(hung).toBeDefined());
122
+ controller.abort();
123
+ await expect(pending).rejects.toThrow();
124
+ await vi.waitFor(() => expect(hung!.aborted).toBe(true));
125
+ });
126
+
127
+ it('reads a resource, gets a prompt and refuses a missing document with invalid params', async () => {
128
+ const policy = {
129
+ id: 'doc-1',
130
+ type: 'policy_security',
131
+ name: 'Security Policy',
132
+ version: 3,
133
+ isDraft: false,
134
+ data: { version: '1.0.0', sections: { summary: { source: 'template', priority: 'core', content: 'Encrypt' } } },
135
+ };
136
+ const { client } = await bootModern(['tenant:documents:read'], {
137
+ 'GET /v1/tenants/current/documents/policy_security': () => json({ data: policy }),
138
+ 'GET /v1/tenants/current/documents': () => json({ data: [policy], nextCursor: null }),
139
+ });
140
+
141
+ const { contents } = await client.readResource({ uri: 'taiga://documents/policy_security' });
142
+ const prompt = await client.getPrompt({
143
+ name: 'taiga_review_against_policies',
144
+ arguments: { policyType: 'policy_security' },
145
+ });
146
+ const missing = await client.readResource({ uri: 'taiga://documents/tone_of_voice' }).catch((e: unknown) => e);
147
+
148
+ expect((contents[0] as { text: string }).text).toContain('# Security Policy');
149
+ expect((prompt.messages[0]!.content as { text: string }).text).toContain('# Security Policy');
150
+ expect(missing).toMatchObject({ code: -32602 });
151
+ expect(String((missing as Error).message)).toMatch(/^No published tone_of_voice/);
152
+ });
153
+ });