@likerts/mcp 0.1.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.
package/dist/main.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { LikertsClient } from './client.js';
4
+ import { createServer } from './server.js';
5
+ const client = new LikertsClient(process.env.LIKERTS_API_URL ?? 'http://127.0.0.1:8080', process.env.LIKERTS_TOKEN, process.env.LIKERTS_COLLECTION_TOKEN, fetch, process.env.LIKERTS_WORKSPACE_ID);
6
+ await createServer(client).connect(new StdioServerTransport());
@@ -0,0 +1,9 @@
1
+ import { createRemoteMcpHttpServer, remoteConfigFromEnvironment } from './remote.js';
2
+ const port = Number.parseInt(process.env.LIKERTS_MCP_PORT ?? '8090', 10);
3
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
4
+ throw new Error('LIKERTS_MCP_PORT must be a valid TCP port');
5
+ const host = process.env.LIKERTS_MCP_BIND_ADDRESS ?? '127.0.0.1';
6
+ const server = createRemoteMcpHttpServer(remoteConfigFromEnvironment());
7
+ server.listen(port, host, () => console.error(`Likerts remote MCP listening on ${host}:${port}`));
8
+ for (const signal of ['SIGINT', 'SIGTERM'])
9
+ process.on(signal, () => server.close(() => process.exit(0)));
package/dist/remote.js ADDED
@@ -0,0 +1,160 @@
1
+ import { createServer } from 'node:http';
2
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
+ import { LikertsClient } from './client.js';
4
+ import { createServer as createLikertsServer } from './server.js';
5
+ const WORKSPACE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
6
+ const MAX_BODY_BYTES = 64 * 1024;
7
+ export function parseOrigin(value, name, allowLoopback = false) {
8
+ let url;
9
+ try {
10
+ url = new URL(value);
11
+ }
12
+ catch {
13
+ throw new Error(`${name} must be a valid origin`);
14
+ }
15
+ const loopback = allowLoopback && url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
16
+ if ((!loopback && url.protocol !== 'https:') || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
17
+ throw new Error(`${name} must be an HTTPS origin without credentials, path, query or fragment`);
18
+ }
19
+ return url;
20
+ }
21
+ export function remoteConfigFromEnvironment(env = process.env) {
22
+ const apiUrl = parseOrigin(env.LIKERTS_API_URL ?? '', 'LIKERTS_API_URL', true).origin;
23
+ const publicOrigin = parseOrigin(env.LIKERTS_MCP_PUBLIC_ORIGIN ?? '', 'LIKERTS_MCP_PUBLIC_ORIGIN', true).origin;
24
+ const authorizationServer = parseOrigin(env.LIKERTS_OIDC_ISSUER ?? '', 'LIKERTS_OIDC_ISSUER').origin;
25
+ const allowedOrigins = new Set((env.LIKERTS_MCP_ALLOWED_ORIGINS ?? '').split(',').filter(Boolean).map(value => parseOrigin(value.trim(), 'LIKERTS_MCP_ALLOWED_ORIGINS', true).origin));
26
+ if (!allowedOrigins.size)
27
+ throw new Error('LIKERTS_MCP_ALLOWED_ORIGINS must contain at least one trusted origin');
28
+ return { apiUrl, publicOrigin, authorizationServer, allowedOrigins };
29
+ }
30
+ function cors(req, res, config) {
31
+ const origin = req.headers.origin;
32
+ if (!origin)
33
+ return true;
34
+ if (!config.allowedOrigins.has(origin)) {
35
+ res.writeHead(403, { 'content-type': 'application/json', vary: 'Origin' }).end(JSON.stringify({ error: 'origin_not_allowed' }));
36
+ return false;
37
+ }
38
+ res.setHeader('access-control-allow-origin', origin);
39
+ res.setHeader('access-control-allow-credentials', 'true');
40
+ res.setHeader('access-control-expose-headers', 'Mcp-Session-Id, WWW-Authenticate');
41
+ res.setHeader('vary', 'Origin');
42
+ return true;
43
+ }
44
+ function metadata(config) {
45
+ return {
46
+ resource: config.publicOrigin,
47
+ authorization_servers: [config.authorizationServer],
48
+ bearer_methods_supported: ['header'],
49
+ scopes_supported: [
50
+ 'surveys:read', 'surveys:write', 'collections:write', 'responses:read', 'responses:write',
51
+ 'usage:read', 'exports:read', 'exports:write', 'identity:write',
52
+ 'webhooks:read', 'webhooks:write'
53
+ ]
54
+ };
55
+ }
56
+ function challenge(config) {
57
+ return `Bearer resource_metadata="${config.publicOrigin}/.well-known/oauth-protected-resource"`;
58
+ }
59
+ async function body(req) {
60
+ const chunks = [];
61
+ let size = 0;
62
+ for await (const chunk of req) {
63
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
64
+ size += bytes.length;
65
+ if (size > MAX_BODY_BYTES)
66
+ throw new Error('payload_too_large');
67
+ chunks.push(bytes);
68
+ }
69
+ try {
70
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
71
+ }
72
+ catch {
73
+ throw new Error('invalid_json');
74
+ }
75
+ }
76
+ export function createRemoteMcpHttpServer(config) {
77
+ // Validate trust-boundary configuration before opening a listening socket.
78
+ parseOrigin(config.apiUrl, 'LIKERTS_API_URL', true);
79
+ parseOrigin(config.publicOrigin, 'LIKERTS_MCP_PUBLIC_ORIGIN', true);
80
+ parseOrigin(config.authorizationServer, 'LIKERTS_OIDC_ISSUER');
81
+ return createServer(async (req, res) => {
82
+ if (req.url === '/health') {
83
+ if (req.method !== 'GET') {
84
+ res.writeHead(405, { allow: 'GET' }).end();
85
+ return;
86
+ }
87
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ service: 'likerts-mcp', status: 'ok' }));
88
+ return;
89
+ }
90
+ if (!cors(req, res, config))
91
+ return;
92
+ const url = new URL(req.url ?? '/', config.publicOrigin);
93
+ if (url.pathname === '/.well-known/oauth-protected-resource') {
94
+ if (req.method === 'OPTIONS') {
95
+ res.writeHead(204, {
96
+ 'access-control-allow-methods': 'GET, OPTIONS',
97
+ 'access-control-allow-headers': 'Authorization, Content-Type, MCP-Protocol-Version, MCP-Session-Id',
98
+ 'access-control-max-age': '600'
99
+ }).end();
100
+ }
101
+ else if (req.method === 'GET') {
102
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'public, max-age=300' }).end(JSON.stringify(metadata(config)));
103
+ }
104
+ else
105
+ res.writeHead(405, { allow: 'GET, OPTIONS' }).end();
106
+ return;
107
+ }
108
+ const match = /^\/mcp\/([^/]+)$/.exec(url.pathname);
109
+ if (!match || !WORKSPACE.test(match[1])) {
110
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'not_found' }));
111
+ return;
112
+ }
113
+ if (req.method === 'OPTIONS') {
114
+ res.writeHead(204, {
115
+ 'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS',
116
+ 'access-control-allow-headers': 'Authorization, Content-Type, MCP-Protocol-Version, MCP-Session-Id, X-Likerts-Collection-Token',
117
+ 'access-control-max-age': '600'
118
+ }).end();
119
+ return;
120
+ }
121
+ const authorization = req.headers.authorization;
122
+ const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
123
+ if (!token || token.trim() !== token || token.includes(' ')) {
124
+ res.writeHead(401, { 'content-type': 'application/json', 'www-authenticate': challenge(config) }).end(JSON.stringify({ error: 'unauthorized' }));
125
+ return;
126
+ }
127
+ if (!['GET', 'POST', 'DELETE'].includes(req.method ?? '')) {
128
+ res.writeHead(405, { allow: 'GET, POST, DELETE, OPTIONS' }).end();
129
+ return;
130
+ }
131
+ let parsed = undefined;
132
+ if (req.method === 'POST') {
133
+ try {
134
+ parsed = await body(req);
135
+ }
136
+ catch (error) {
137
+ const tooLarge = error instanceof Error && error.message === 'payload_too_large';
138
+ res.writeHead(tooLarge ? 413 : 400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: tooLarge ? 'payload_too_large' : 'invalid_json' }));
139
+ return;
140
+ }
141
+ }
142
+ const collectionHeader = req.headers['x-likerts-collection-token'];
143
+ const collectionToken = typeof collectionHeader === 'string' && collectionHeader.length <= 512 ? collectionHeader : undefined;
144
+ const client = new LikertsClient(config.apiUrl, token, collectionToken, config.fetch ?? fetch, match[1]);
145
+ const server = createLikertsServer(client);
146
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
147
+ try {
148
+ await server.connect(transport);
149
+ await transport.handleRequest(req, res, parsed);
150
+ }
151
+ catch {
152
+ if (!res.headersSent)
153
+ res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null }));
154
+ }
155
+ finally {
156
+ await transport.close();
157
+ await server.close();
158
+ }
159
+ });
160
+ }
@@ -0,0 +1,8 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ // npm archives carry their contracts beside the compiled code. A source checkout
3
+ // and the existing container layout continue to read the canonical repository files.
4
+ export function readContractResource(name) {
5
+ const bundled = new URL(`./data/${name}`, import.meta.url);
6
+ const checkout = new URL(name === 'capabilities.json' ? '../../capabilities.json' : '../../../contracts/openapi.json', import.meta.url);
7
+ return JSON.parse(readFileSync(existsSync(bundled) ? bundled : checkout, 'utf8'));
8
+ }
package/dist/server.js ADDED
@@ -0,0 +1,35 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
3
+ import { capabilities, LikertsHttpError } from './client.js';
4
+ import { operationContracts } from './contract.js';
5
+ export function createServer(client) {
6
+ const server = new Server({ name: 'likerts', version: '0.1.0' }, { capabilities: { tools: {} } });
7
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: capabilities.map(capability => ({
8
+ name: capability.name,
9
+ description: capability.description,
10
+ inputSchema: operationContracts.get(capability.name).inputSchema,
11
+ outputSchema: operationContracts.get(capability.name).outputSchema,
12
+ annotations: { readOnlyHint: capability.method === 'GET', destructiveHint: capability.method !== 'GET', openWorldHint: false }
13
+ })) }));
14
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
15
+ const contract = operationContracts.get(request.params.name);
16
+ const failure = (code, message) => ({ isError: true, content: [{ type: 'text', text: JSON.stringify({ error: { code, message } }) }] });
17
+ if (!contract)
18
+ return failure('unknown_capability', 'Unknown Likerts capability');
19
+ const input = request.params.arguments ?? {};
20
+ if (!contract.validateInput(input))
21
+ return failure('invalid_request', 'Arguments do not match the published input schema');
22
+ try {
23
+ const data = await client.call(request.params.name, input);
24
+ if (!contract.validateOutput({ result: data }))
25
+ return failure('invalid_response', 'Server response does not match the published output schema');
26
+ return { content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: { result: data } };
27
+ }
28
+ catch (error) {
29
+ if (error instanceof LikertsHttpError)
30
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: { code: 'http_error', message: error.message, status: error.status, operation: error.operation } }) }] };
31
+ return failure('request_failed', 'Likerts request failed');
32
+ }
33
+ });
34
+ return server;
35
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@likerts/mcp",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public",
7
+ "registry": "https://registry.npmjs.org/"
8
+ },
9
+ "license": "MIT",
10
+ "type": "module",
11
+ "scripts": {
12
+ "build": "tsc && node scripts/package-data.mjs",
13
+ "test": "tsx --test test/*.test.ts",
14
+ "start": "node dist/main.js",
15
+ "start:remote": "node dist/remote-main.js",
16
+ "prepack": "npm run build"
17
+ },
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "1.30.0",
20
+ "ajv": "8.20.0",
21
+ "zod": "^3.25.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.0.0",
25
+ "tsx": "^4.20.0",
26
+ "typescript": "^5.9.0"
27
+ },
28
+ "description": "Typed MCP server for the free Likerts survey collection platform.",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/crosstabs/likerts.git",
32
+ "directory": "tools/mcp"
33
+ },
34
+ "homepage": "https://likerts.com/docs",
35
+ "bugs": {
36
+ "url": "https://github.com/crosstabs/likerts/issues"
37
+ },
38
+ "engines": {
39
+ "node": ">=22"
40
+ },
41
+ "keywords": [
42
+ "likerts",
43
+ "surveys",
44
+ "feedback",
45
+ "mcp",
46
+ "model-context-protocol"
47
+ ],
48
+ "files": [
49
+ "dist",
50
+ "README.md",
51
+ "LICENSE"
52
+ ],
53
+ "bin": {
54
+ "likerts-mcp": "dist/main.js"
55
+ },
56
+ "mcpName": "io.github.crosstabs/likerts"
57
+ }