@joinquest/mcp-integration 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/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # JoinQuest integration MCP server
2
+
3
+ stdio MCP server for **game developers** integrating with JoinQuest. Exposes developer dashboard operations to Cursor, Claude, and other agents via the same GraphQL API as the portal.
4
+
5
+ ## Quick setup
6
+
7
+ 1. Sign in at [JoinQuest](https://joinquest.cc) and open your **developer dashboard**.
8
+ 2. **Connect an AI assistant** → **Show setup** → **Generate API key** (copy once).
9
+ 3. Add MCP config (Node.js 20+, uses `npx` — no install step):
10
+
11
+ **Cursor** — `.cursor/mcp.json`:
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "joinquest-integration": {
17
+ "type": "stdio",
18
+ "command": "npx",
19
+ "args": ["-y", "@joinquest/mcp-integration", "joinquest-integration-mcp-cursor"],
20
+ "env": {
21
+ "JOINQUEST_API_KEY": "lq_dev_..."
22
+ }
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ **Claude Code:**
29
+
30
+ ```bash
31
+ claude mcp add --scope project --transport stdio \
32
+ --env JOINQUEST_API_KEY=lq_dev_... \
33
+ joinquest-integration -- npx -y @joinquest/mcp-integration
34
+ ```
35
+
36
+ **Other stdio clients** — use `npx` with args `["-y", "@joinquest/mcp-integration"]` and the same `env`.
37
+
38
+ 4. Fully quit and reopen your agent client. Test: ask the agent to call `joinquest_integration_list_my_games`.
39
+
40
+ ### Why a separate Cursor bin?
41
+
42
+ Cursor injects `ELECTRON_RUN_AS_NODE` into MCP child processes. Use the second npx arg `joinquest-integration-mcp-cursor` (wrapper that unsets it). Other clients use the default bin.
43
+
44
+ ### Optional global install
45
+
46
+ ```bash
47
+ curl -fsSL https://raw.githubusercontent.com/scruffyprodigy/playhub/main/scripts/install-joinquest-mcp.sh | sh
48
+ ```
49
+
50
+ ## Environment
51
+
52
+ | Variable | Required | Description |
53
+ |----------|----------|-------------|
54
+ | `JOINQUEST_API_KEY` | Yes* | From developer dashboard → Connect AI assistant |
55
+ | `JOINQUEST_ISSUER_URL` | No | Lobby JWT issuer / provision `lobbyId` |
56
+ | `JOINQUEST_PUBLIC_URL` | No | Browser Lobby URL for example provision payloads |
57
+ | `JOINQUEST_SESSION` | Legacy | Session cookie (prefer API key) |
58
+
59
+ Advanced (lobby contributors): `JOINQUEST_API_URL=http://localhost:8080/graphql` — see [docs/development.md](../../docs/development.md).
60
+
61
+ ## Publishing
62
+
63
+ Maintainers: `./scripts/publish-joinquest-mcp.sh` (requires npm login + `@joinquest` scope). Roadmap: [docs/developer-ai-setup-roadmap.md](../../docs/developer-ai-setup-roadmap.md).
64
+
65
+ ## Client config paths
66
+
67
+ | Client | Config file |
68
+ |--------|-------------|
69
+ | Cursor | `~/.cursor/mcp.json` or `.cursor/mcp.json` |
70
+ | Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` |
71
+ | Claude Code | `.mcp.json` at project root |
72
+
73
+ ## Tools
74
+
75
+ | Tool | Purpose |
76
+ |------|---------|
77
+ | `joinquest_integration_get_agent_playbook` | **Start here** — end-to-end agent workflow (phases 1–8) |
78
+ | `joinquest_integration_get_integration_guide` | Full integration guide (markdown) |
79
+ | `joinquest_integration_get_discovery_prompt` | Agent interview script |
80
+ | `joinquest_integration_get_catalog_tag_taxonomy` | Valid tag IDs |
81
+ | `joinquest_integration_list_my_games` | Owner's games + visibility |
82
+ | `joinquest_integration_get_game_checks` | Checklist + metadata |
83
+ | `joinquest_integration_run_game_checks` | Run manifest / provision / JWT suite |
84
+ | `joinquest_integration_update_game_metadata` | Save catalog copy + tags |
85
+ | `joinquest_integration_get_game_credentials` | serviceToken + webhook secret |
86
+ | `joinquest_integration_get_example_provision_payload` | Sample provision JSON |
87
+ | `joinquest_integration_request_public_release` | Submit for catalog review |
88
+
89
+ ## Run from repo
90
+
91
+ ```bash
92
+ cd mcp/joinquest-integration
93
+ npm install
94
+ JOINQUEST_API_KEY=your-key node src/index.js
95
+ ```
96
+
97
+ ## Tests
98
+
99
+ ```bash
100
+ npm test
101
+ ```
102
+
103
+ ## Publishing (maintainers)
104
+
105
+ From repo root (requires `@joinquest` scope access + npm 2FA):
106
+
107
+ ```bash
108
+ NPM_OTP=123456 ./scripts/publish-joinquest-mcp.sh
109
+ ```
110
+
111
+ Use a code from your authenticator app when prompted. For GitHub Actions, add a **granular access token** with **Publish** on `@joinquest/*` and **bypass 2FA** enabled as `NPM_TOKEN`.
@@ -0,0 +1,11 @@
1
+ #!/bin/bash
2
+ # Cursor injects ELECTRON_RUN_AS_NODE and a bundled node on PATH, which breaks stdio MCP.
3
+ set -eu
4
+
5
+ unset ELECTRON_RUN_AS_NODE
6
+ export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${PATH:-}"
7
+
8
+ DIR="$(cd "$(dirname "$0")" && pwd)"
9
+ NODE="${JOINQUEST_NODE:-$(command -v node)}"
10
+
11
+ exec "$NODE" "$DIR/../src/index.js"
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@joinquest/mcp-integration",
3
+ "version": "0.1.0",
4
+ "description": "JoinQuest integration MCP server for game developer agents",
5
+ "type": "module",
6
+ "private": false,
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "joinquest",
10
+ "mcp",
11
+ "model-context-protocol",
12
+ "game-development",
13
+ "integration"
14
+ ],
15
+ "files": [
16
+ "bin",
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/scruffyprodigy/playhub.git",
23
+ "directory": "mcp/joinquest-integration"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/scruffyprodigy/playhub/issues"
27
+ },
28
+ "homepage": "https://joinquest.cc/developers",
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "bin": {
33
+ "joinquest-integration-mcp": "src/index.js",
34
+ "joinquest-integration-mcp-cursor": "bin/joinquest-integration-mcp-cursor"
35
+ },
36
+ "scripts": {
37
+ "start": "node src/index.js",
38
+ "test": "node --test test/*.test.js",
39
+ "prepublishOnly": "npm test"
40
+ },
41
+ "engines": {
42
+ "node": ">=20"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.29.0",
46
+ "zod": "^3.25.76"
47
+ }
48
+ }
package/src/config.js ADDED
@@ -0,0 +1,21 @@
1
+ export const DEFAULT_JOINQUEST_API_URL = 'https://joinquest.cc/graphql'
2
+
3
+ export function loadConfig() {
4
+ const apiUrl = (process.env.JOINQUEST_API_URL || DEFAULT_JOINQUEST_API_URL).trim()
5
+ const apiKey = (process.env.JOINQUEST_API_KEY || '').trim()
6
+ const session = (process.env.JOINQUEST_SESSION || process.env.JOINQUEST_SESSION_COOKIE || '').trim()
7
+ const cookieName = (process.env.JOINQUEST_SESSION_COOKIE_NAME || 'lobby_session').trim()
8
+
9
+ if (apiKey) {
10
+ return { apiUrl, authHeader: `Bearer ${apiKey}` }
11
+ }
12
+
13
+ if (session) {
14
+ const cookieValue = session.includes('=') ? session : `${cookieName}=${session}`
15
+ return { apiUrl, cookieValue }
16
+ }
17
+
18
+ throw new Error(
19
+ 'JOINQUEST_API_KEY is required. Generate one on your developer dashboard (Connect AI assistant), then set JOINQUEST_API_KEY in the MCP server env.',
20
+ )
21
+ }
@@ -0,0 +1,36 @@
1
+ const CHECK_USER_ONE = 'a0000000-0000-4000-8000-000000000001'
2
+ const CHECK_USER_TWO = 'a0000000-0000-4000-8000-000000000002'
3
+
4
+ export function buildExampleProvisionPayload({ game, credentials, lobbyIssuer, lobbyReturnUrl, lobbyGraphqlUrl }) {
5
+ const mode = game?.modes?.[0]
6
+ if (!mode) {
7
+ throw new Error('Game has no synced modes — connect API and sync manifest first.')
8
+ }
9
+
10
+ const seats = mode.seats ?? []
11
+ const seatCount = Math.min(2, seats.length)
12
+ if (seatCount === 0) {
13
+ throw new Error('Game mode has no expanded seats.')
14
+ }
15
+
16
+ const externalMatchId = 'example-match-' + game.id.slice(0, 8)
17
+ const assignmentSeats = seats.slice(0, seatCount).map((seat, index) => ({
18
+ seatKey: seat.seatKey,
19
+ lobbyUserId: index === 0 ? CHECK_USER_ONE : CHECK_USER_TWO,
20
+ displayName: index === 0 ? 'Player One' : 'Player Two',
21
+ }))
22
+
23
+ return {
24
+ lobbyId: lobbyIssuer,
25
+ lobby: {
26
+ returnUrl: lobbyReturnUrl,
27
+ graphqlUrl: lobbyGraphqlUrl,
28
+ serviceToken: credentials.serviceToken,
29
+ },
30
+ assignment: {
31
+ externalMatchId,
32
+ gameMode: mode.modeKey,
33
+ seats: assignmentSeats,
34
+ },
35
+ }
36
+ }
package/src/graphql.js ADDED
@@ -0,0 +1,146 @@
1
+ export async function graphqlRequest(apiUrl, auth, query, variables = {}) {
2
+ const headers = {
3
+ 'Content-Type': 'application/json',
4
+ }
5
+ if (auth.authHeader) {
6
+ headers.Authorization = auth.authHeader
7
+ } else if (auth.cookieValue) {
8
+ headers.Cookie = auth.cookieValue
9
+ }
10
+
11
+ const response = await fetch(apiUrl, {
12
+ method: 'POST',
13
+ headers,
14
+ body: JSON.stringify({ query, variables }),
15
+ })
16
+
17
+ let payload
18
+ try {
19
+ payload = await response.json()
20
+ } catch {
21
+ throw new Error(`JoinQuest API returned non-JSON (${response.status})`)
22
+ }
23
+
24
+ if (!response.ok) {
25
+ const detail = payload.errors?.[0]?.message || payload.error || response.statusText
26
+ throw new Error(detail || `API request failed (${response.status})`)
27
+ }
28
+
29
+ if (payload.errors?.length) {
30
+ throw new Error(payload.errors[0]?.message || 'GraphQL request failed')
31
+ }
32
+
33
+ return payload.data
34
+ }
35
+
36
+ export const QUERIES = {
37
+ agentPlaybook: `
38
+ query DeveloperAgentPlaybook {
39
+ developerAgentPlaybook
40
+ }
41
+ `,
42
+ integrationGuide: `
43
+ query DeveloperIntegrationGuide {
44
+ developerIntegrationGuide
45
+ }
46
+ `,
47
+ discoveryPrompt: `
48
+ query DeveloperDiscoveryPrompt {
49
+ developerDiscoveryPrompt
50
+ }
51
+ `,
52
+ catalogTagTaxonomy: `
53
+ query CatalogTagTaxonomy {
54
+ catalogTagTaxonomy {
55
+ id
56
+ label
57
+ description
58
+ }
59
+ }
60
+ `,
61
+ myGames: `
62
+ query MyGames {
63
+ myGames {
64
+ id
65
+ slug
66
+ name
67
+ shortDescription
68
+ visibility
69
+ apiBaseUrl
70
+ }
71
+ }
72
+ `,
73
+ myGame: `
74
+ query MyGame($id: ID!) {
75
+ myGame(id: $id) {
76
+ id
77
+ slug
78
+ name
79
+ shortDescription
80
+ longDescription
81
+ howToPlay
82
+ tags
83
+ visibility
84
+ apiBaseUrl
85
+ integrationChecks {
86
+ checkId
87
+ status
88
+ message
89
+ detail
90
+ ranAt
91
+ }
92
+ modes {
93
+ modeKey
94
+ displayName
95
+ minPlayers
96
+ maxPlayers
97
+ seats {
98
+ seatKey
99
+ queuePath
100
+ }
101
+ }
102
+ }
103
+ }
104
+ `,
105
+ myGameCredentials: `
106
+ query MyGameCredentials($id: ID!) {
107
+ myGameCredentials(id: $id) {
108
+ serviceToken
109
+ webhookSecret
110
+ }
111
+ }
112
+ `,
113
+ }
114
+
115
+ export const MUTATIONS = {
116
+ runMyGameChecks: `
117
+ mutation RunMyGameChecks($gameId: ID!) {
118
+ runMyGameChecks(gameId: $gameId) {
119
+ checkId
120
+ status
121
+ message
122
+ detail
123
+ ranAt
124
+ }
125
+ }
126
+ `,
127
+ updateMyGameMetadata: `
128
+ mutation UpdateMyGameMetadata($input: UpdateMyGameMetadataInput!) {
129
+ updateMyGameMetadata(input: $input) {
130
+ id
131
+ shortDescription
132
+ longDescription
133
+ howToPlay
134
+ tags
135
+ }
136
+ }
137
+ `,
138
+ requestPublicRelease: `
139
+ mutation RequestPublicRelease($gameId: ID!) {
140
+ requestPublicRelease(gameId: $gameId) {
141
+ id
142
+ visibility
143
+ }
144
+ }
145
+ `,
146
+ }
package/src/index.js ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
5
+ import { loadConfig } from './config.js'
6
+ import { registerJoinQuestIntegrationTools } from './tools.js'
7
+
8
+ async function main() {
9
+ const config = loadConfig()
10
+ const server = new McpServer({
11
+ name: 'joinquest-integration',
12
+ version: '0.1.0',
13
+ })
14
+
15
+ registerJoinQuestIntegrationTools(server, config)
16
+
17
+ const transport = new StdioServerTransport()
18
+ await server.connect(transport)
19
+ console.error('JoinQuest integration MCP server running (stdio)')
20
+ }
21
+
22
+ main().catch((error) => {
23
+ console.error('JoinQuest integration MCP fatal error:', error.message)
24
+ process.exit(1)
25
+ })
package/src/tools.js ADDED
@@ -0,0 +1,206 @@
1
+ import { z } from 'zod'
2
+ import {
3
+ MUTATIONS,
4
+ QUERIES,
5
+ graphqlRequest,
6
+ } from './graphql.js'
7
+ import { buildExampleProvisionPayload } from './example-provision.js'
8
+
9
+ function textResult(value) {
10
+ const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
11
+ return { content: [{ type: 'text', text }] }
12
+ }
13
+
14
+ function lobbyUrls(apiUrl) {
15
+ const origin = apiUrl.replace(/\/graphql\/?$/, '')
16
+ const issuer = (process.env.JOINQUEST_ISSUER_URL || origin).replace(/\/$/, '')
17
+ let publicUrl = process.env.JOINQUEST_PUBLIC_URL || origin
18
+ if (origin.includes('localhost:8080') && !process.env.JOINQUEST_PUBLIC_URL) {
19
+ publicUrl = 'http://localhost:5173'
20
+ }
21
+ publicUrl = publicUrl.replace(/\/$/, '')
22
+ return {
23
+ lobbyIssuer: issuer,
24
+ lobbyReturnUrl: `${publicUrl}/return`,
25
+ lobbyGraphqlUrl: `${issuer}/graphql`,
26
+ }
27
+ }
28
+
29
+ export function registerJoinQuestIntegrationTools(server, config) {
30
+ const { apiUrl, authHeader, cookieValue } = config
31
+ const auth = authHeader ? { authHeader } : { cookieValue }
32
+ const gql = (query, variables) => graphqlRequest(apiUrl, auth, query, variables)
33
+
34
+ server.registerTool(
35
+ 'joinquest_integration_get_agent_playbook',
36
+ {
37
+ description:
38
+ 'Returns the end-to-end agent workflow for integrating a game with JoinQuest (phases 1–8: discovery, MCP setup, API implementation, registration, checks, metadata, test, release). Start here for vibe-coding integrations.',
39
+ inputSchema: z.object({}),
40
+ },
41
+ async () => {
42
+ const data = await gql(QUERIES.agentPlaybook)
43
+ return textResult(data.developerAgentPlaybook)
44
+ },
45
+ )
46
+
47
+ server.registerTool(
48
+ 'joinquest_integration_get_integration_guide',
49
+ {
50
+ description: 'Returns the full JoinQuest developer integration guide (markdown).',
51
+ inputSchema: z.object({}),
52
+ },
53
+ async () => {
54
+ const data = await gql(QUERIES.integrationGuide)
55
+ return textResult(data.developerIntegrationGuide)
56
+ },
57
+ )
58
+
59
+ server.registerTool(
60
+ 'joinquest_integration_get_discovery_prompt',
61
+ {
62
+ description: 'Returns the agent discovery interview script for understanding a game before drafting catalog copy or seatTemplate guidance.',
63
+ inputSchema: z.object({}),
64
+ },
65
+ async () => {
66
+ const data = await gql(QUERIES.discoveryPrompt)
67
+ return textResult(data.developerDiscoveryPrompt)
68
+ },
69
+ )
70
+
71
+ server.registerTool(
72
+ 'joinquest_integration_get_catalog_tag_taxonomy',
73
+ {
74
+ description: 'Returns valid catalog tag IDs and labels for updateMyGameMetadata.',
75
+ inputSchema: z.object({}),
76
+ },
77
+ async () => {
78
+ const data = await gql(QUERIES.catalogTagTaxonomy)
79
+ return textResult(data.catalogTagTaxonomy)
80
+ },
81
+ )
82
+
83
+ server.registerTool(
84
+ 'joinquest_integration_list_my_games',
85
+ {
86
+ description: "List games owned by the signed-in developer, including visibility state.",
87
+ inputSchema: z.object({}),
88
+ },
89
+ async () => {
90
+ const data = await gql(QUERIES.myGames)
91
+ return textResult(data.myGames ?? [])
92
+ },
93
+ )
94
+
95
+ server.registerTool(
96
+ 'joinquest_integration_get_game_checks',
97
+ {
98
+ description: 'Fetch latest integration checklist results and catalog metadata for one owned game.',
99
+ inputSchema: z.object({
100
+ gameId: z.string().describe('JoinQuest game UUID'),
101
+ }),
102
+ },
103
+ async ({ gameId }) => {
104
+ const data = await gql(QUERIES.myGame, { id: gameId })
105
+ if (!data.myGame) {
106
+ throw new Error('Game not found or not owned by this session.')
107
+ }
108
+ return textResult(data.myGame)
109
+ },
110
+ )
111
+
112
+ server.registerTool(
113
+ 'joinquest_integration_run_game_checks',
114
+ {
115
+ description: 'Run manifest, provision, and JWT integration checks for an owned game.',
116
+ inputSchema: z.object({
117
+ gameId: z.string().describe('JoinQuest game UUID'),
118
+ }),
119
+ },
120
+ async ({ gameId }) => {
121
+ const data = await gql(MUTATIONS.runMyGameChecks, { gameId })
122
+ return textResult(data.runMyGameChecks ?? [])
123
+ },
124
+ )
125
+
126
+ server.registerTool(
127
+ 'joinquest_integration_update_game_metadata',
128
+ {
129
+ description: 'Save catalog listing copy and tags after developer approval. Use catalogTagTaxonomy IDs.',
130
+ inputSchema: z.object({
131
+ gameId: z.string(),
132
+ shortDescription: z.string().optional(),
133
+ longDescription: z.string().optional(),
134
+ howToPlay: z.string().optional(),
135
+ tags: z.array(z.string()).optional(),
136
+ }),
137
+ },
138
+ async ({ gameId, shortDescription, longDescription, howToPlay, tags }) => {
139
+ const input = { gameId }
140
+ if (shortDescription !== undefined) input.shortDescription = shortDescription
141
+ if (longDescription !== undefined) input.longDescription = longDescription
142
+ if (howToPlay !== undefined) input.howToPlay = howToPlay
143
+ if (tags !== undefined) input.tags = tags
144
+
145
+ const data = await gql(MUTATIONS.updateMyGameMetadata, { input })
146
+ return textResult(data.updateMyGameMetadata)
147
+ },
148
+ )
149
+
150
+ server.registerTool(
151
+ 'joinquest_integration_get_game_credentials',
152
+ {
153
+ description: 'Return serviceToken and webhookSecret for an owned game (sensitive).',
154
+ inputSchema: z.object({
155
+ gameId: z.string(),
156
+ }),
157
+ },
158
+ async ({ gameId }) => {
159
+ const data = await gql(QUERIES.myGameCredentials, { id: gameId })
160
+ if (!data.myGameCredentials) {
161
+ throw new Error('Credentials not found or game not owned by this session.')
162
+ }
163
+ return textResult(data.myGameCredentials)
164
+ },
165
+ )
166
+
167
+ server.registerTool(
168
+ 'joinquest_integration_get_example_provision_payload',
169
+ {
170
+ description: 'Build a sample POST /api/v1/matches body for local testing, using synced modes and credentials.',
171
+ inputSchema: z.object({
172
+ gameId: z.string(),
173
+ }),
174
+ },
175
+ async ({ gameId }) => {
176
+ const [gameData, credData] = await Promise.all([
177
+ gql(QUERIES.myGame, { id: gameId }),
178
+ gql(QUERIES.myGameCredentials, { id: gameId }),
179
+ ])
180
+ if (!gameData.myGame || !credData.myGameCredentials) {
181
+ throw new Error('Game or credentials not found.')
182
+ }
183
+ const urls = lobbyUrls(apiUrl)
184
+ const payload = buildExampleProvisionPayload({
185
+ game: gameData.myGame,
186
+ credentials: credData.myGameCredentials,
187
+ ...urls,
188
+ })
189
+ return textResult(payload)
190
+ },
191
+ )
192
+
193
+ server.registerTool(
194
+ 'joinquest_integration_request_public_release',
195
+ {
196
+ description: 'Submit an owned game for public catalog review when checklist and metadata gates pass.',
197
+ inputSchema: z.object({
198
+ gameId: z.string(),
199
+ }),
200
+ },
201
+ async ({ gameId }) => {
202
+ const data = await gql(MUTATIONS.requestPublicRelease, { gameId })
203
+ return textResult(data.requestPublicRelease)
204
+ },
205
+ )
206
+ }