@foundation0/git 1.2.1 → 1.2.3

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/mcp/src/cli.ts CHANGED
@@ -41,14 +41,18 @@ if (hasFlag('--help') || hasFlag('-h')) {
41
41
  process.exit(0)
42
42
  }
43
43
 
44
- const owner = getArgValue('--default-owner', process.env.GITEA_TEST_OWNER ?? 'example-org')
45
- const repo = getArgValue('--default-repo', process.env.GITEA_TEST_REPO ?? 'example-repo')
46
- const host = getArgValue('--gitea-host', process.env.GITEA_HOST ?? process.env.EXAMPLE_GITEA_HOST ?? 'https://gitea.example.com')?.trim()
44
+ const owner = getArgValue('--default-owner')?.trim()
45
+ const repo = getArgValue('--default-repo')?.trim()
46
+ const host = getArgValue('--gitea-host', process.env.GITEA_HOST)?.trim()
47
47
  const token = process.env.GITEA_TOKEN
48
48
  const serverName = getArgValue('--server-name', 'f0-git-mcp')
49
49
  const serverVersion = getArgValue('--server-version', '1.0.0')
50
50
  const toolsPrefix = getArgValue('--tools-prefix') ?? process.env.MCP_TOOLS_PREFIX
51
51
 
52
+ if (!host) {
53
+ throw new Error('GITEA_HOST is required. Set process.env.GITEA_HOST or pass --gitea-host.')
54
+ }
55
+
52
56
  if (isInsecureHttpUrl(host) && !hasFlag('--allow-insecure-http')) {
53
57
  throw new Error(
54
58
  'Refusing to send requests to an insecure http:// Gitea host. Use https:// or pass --allow-insecure-http if you really need this for local testing.',
@@ -63,8 +67,8 @@ void runGitMcpServer({
63
67
  giteaHost: host,
64
68
  giteaToken: token,
65
69
  },
66
- defaultOwner: owner,
67
- defaultRepo: repo,
70
+ ...(owner ? { defaultOwner: owner } : {}),
71
+ ...(repo ? { defaultRepo: repo } : {}),
68
72
  toolsPrefix,
69
73
  }).catch((error) => {
70
74
  console.error('Failed to start MCP git server', error)
@@ -0,0 +1,207 @@
1
+ const REDACTED = '[REDACTED]'
2
+
3
+ const normalizeKey = (value: string): string => value.toLowerCase().replace(/[^a-z0-9]/g, '')
4
+
5
+ const SENSITIVE_OBJECT_KEYS = new Set(
6
+ [
7
+ 'authorization',
8
+ 'proxyauthorization',
9
+ 'cookie',
10
+ 'setcookie',
11
+ 'xapikey',
12
+ 'xauthtoken',
13
+ 'xaccesstoken',
14
+ 'apikey',
15
+ 'accesstoken',
16
+ 'refreshtoken',
17
+ 'idtoken',
18
+ 'clientsecret',
19
+ 'secret',
20
+ 'password',
21
+ 'passphrase',
22
+ 'privatekey',
23
+ 'token',
24
+ 'session',
25
+ 'sessionid',
26
+ ].map(normalizeKey),
27
+ )
28
+
29
+ const SENSITIVE_QUERY_KEYS = new Set(
30
+ [
31
+ 'access_token',
32
+ 'refresh_token',
33
+ 'id_token',
34
+ 'token',
35
+ 'api_key',
36
+ 'apikey',
37
+ 'key',
38
+ 'auth',
39
+ 'authorization',
40
+ 'client_secret',
41
+ ].map(normalizeKey),
42
+ )
43
+
44
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
45
+ typeof value === 'object' && value !== null && !Array.isArray(value)
46
+
47
+ const isSensitiveObjectKey = (key: string): boolean => SENSITIVE_OBJECT_KEYS.has(normalizeKey(key))
48
+
49
+ const isSensitiveQueryKey = (key: string): boolean => SENSITIVE_QUERY_KEYS.has(normalizeKey(key))
50
+
51
+ const redactQueryEntry = (entry: string): string => {
52
+ const separatorIndex = entry.indexOf('=')
53
+ if (separatorIndex < 0) return entry
54
+
55
+ const key = entry.slice(0, separatorIndex).trim()
56
+ if (!key || !isSensitiveQueryKey(key)) return entry
57
+
58
+ return `${key}=${REDACTED}`
59
+ }
60
+
61
+ const redactHeaderLine = (entry: string): string => {
62
+ const separatorIndex = entry.indexOf(':')
63
+ if (separatorIndex < 0) return entry
64
+
65
+ const name = entry.slice(0, separatorIndex).trim()
66
+ if (!name || !isSensitiveObjectKey(name)) return entry
67
+
68
+ return `${name}: ${REDACTED}`
69
+ }
70
+
71
+ const redactHeaderRecord = (headers: Record<string, unknown>): Record<string, unknown> => {
72
+ const next: Record<string, unknown> = {}
73
+
74
+ for (const [key, value] of Object.entries(headers)) {
75
+ if (isSensitiveObjectKey(key)) {
76
+ next[key] = REDACTED
77
+ continue
78
+ }
79
+
80
+ next[key] = value
81
+ }
82
+
83
+ return next
84
+ }
85
+
86
+ const redactUrl = (value: string): string => {
87
+ const trimmed = value.trim()
88
+ if (!trimmed) return value
89
+
90
+ try {
91
+ const url = new URL(trimmed)
92
+ for (const key of Array.from(url.searchParams.keys())) {
93
+ if (isSensitiveQueryKey(key)) {
94
+ url.searchParams.set(key, REDACTED)
95
+ }
96
+ }
97
+ return url.toString()
98
+ } catch {
99
+ return value
100
+ }
101
+ }
102
+
103
+ const looksLikeHeaderList = (value: unknown[]): value is string[] =>
104
+ value.length === 0 || value.every((entry) => typeof entry === 'string' && entry.includes(':'))
105
+
106
+ const looksLikeQueryList = (value: unknown[]): value is string[] =>
107
+ value.length === 0 || value.every((entry) => typeof entry === 'string' && entry.includes('='))
108
+
109
+ const shouldTreatAsUrlKey = (key: string): boolean => normalizeKey(key) === 'url'
110
+
111
+ const shouldTreatAsHeadersKey = (key: string): boolean => normalizeKey(key) === 'headers'
112
+
113
+ const shouldTreatAsQueryKey = (key: string): boolean => normalizeKey(key) === 'query'
114
+
115
+ export const redactSecretsForMcpOutput = (value: unknown): unknown => {
116
+ const seen = new WeakMap<object, unknown>()
117
+
118
+ const redact = (current: unknown, keyHint?: string): unknown => {
119
+ if (current === null || current === undefined) {
120
+ return current
121
+ }
122
+
123
+ if (typeof current === 'string') {
124
+ if (keyHint && shouldTreatAsUrlKey(keyHint)) {
125
+ return redactUrl(current)
126
+ }
127
+ return current
128
+ }
129
+
130
+ if (typeof current !== 'object') {
131
+ return current
132
+ }
133
+
134
+ if (seen.has(current as object)) {
135
+ return seen.get(current as object)
136
+ }
137
+
138
+ if (Array.isArray(current)) {
139
+ if (keyHint && shouldTreatAsHeadersKey(keyHint) && looksLikeHeaderList(current)) {
140
+ return current.map(redactHeaderLine)
141
+ }
142
+
143
+ if (keyHint && shouldTreatAsQueryKey(keyHint) && looksLikeQueryList(current)) {
144
+ return current.map(redactQueryEntry)
145
+ }
146
+
147
+ const next = current.map((entry) => redact(entry))
148
+ seen.set(current, next)
149
+ return next
150
+ }
151
+
152
+ if (!isRecord(current)) {
153
+ return current
154
+ }
155
+
156
+ if (keyHint && shouldTreatAsHeadersKey(keyHint)) {
157
+ const next = redactHeaderRecord(current)
158
+ seen.set(current, next)
159
+ return next
160
+ }
161
+
162
+ const next: Record<string, unknown> = {}
163
+ seen.set(current, next)
164
+
165
+ for (const [key, entryValue] of Object.entries(current)) {
166
+ if (isSensitiveObjectKey(key)) {
167
+ next[key] = REDACTED
168
+ continue
169
+ }
170
+
171
+ if (shouldTreatAsHeadersKey(key) && isRecord(entryValue)) {
172
+ next[key] = redactHeaderRecord(entryValue)
173
+ continue
174
+ }
175
+
176
+ if (shouldTreatAsHeadersKey(key) && Array.isArray(entryValue) && looksLikeHeaderList(entryValue)) {
177
+ next[key] = entryValue.map(redactHeaderLine)
178
+ continue
179
+ }
180
+
181
+ if (shouldTreatAsQueryKey(key) && Array.isArray(entryValue) && looksLikeQueryList(entryValue)) {
182
+ next[key] = entryValue.map(redactQueryEntry)
183
+ continue
184
+ }
185
+
186
+ next[key] = redact(entryValue, key)
187
+ }
188
+
189
+ return next
190
+ }
191
+
192
+ return redact(value)
193
+ }
194
+
195
+ export const redactSecretsInText = (text: string): string => {
196
+ if (!text) return text
197
+
198
+ const redactedHeaders = text.replace(
199
+ /(Authorization|Proxy-Authorization|Cookie|Set-Cookie|X-API-Key|X-Auth-Token|X-Access-Token)\s*:\s*([^\r\n]*)/gi,
200
+ (_match, name: string) => `${name}: ${REDACTED}`,
201
+ )
202
+
203
+ return redactedHeaders.replace(
204
+ /(access_token|refresh_token|id_token|token|api_key|apikey|client_secret)\s*=\s*([^&\s]+)/gi,
205
+ (_match, key: string) => `${key}=${REDACTED}`,
206
+ )
207
+ }
package/mcp/src/server.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
9
9
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
10
10
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
11
+ import { redactSecretsForMcpOutput, redactSecretsInText } from './redaction'
11
12
 
12
13
  type ToolInvocationPayload = {
13
14
  args?: unknown[]
@@ -299,7 +300,7 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
299
300
  index,
300
301
  tool,
301
302
  isError: false,
302
- data,
303
+ data: redactSecretsForMcpOutput(data),
303
304
  } as BatchResult
304
305
  } catch (error) {
305
306
  if (continueOnError) {
@@ -307,7 +308,7 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
307
308
  index,
308
309
  tool,
309
310
  isError: true,
310
- data: error instanceof Error ? error.message : String(error),
311
+ data: redactSecretsInText(error instanceof Error ? error.message : String(error)),
311
312
  } as BatchResult
312
313
  }
313
314
  throw error
@@ -320,7 +321,7 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
320
321
  content: [
321
322
  {
322
323
  type: 'text',
323
- text: JSON.stringify(results, null, 2),
324
+ text: JSON.stringify(redactSecretsForMcpOutput(results), null, 2),
324
325
  },
325
326
  ],
326
327
  }
@@ -330,7 +331,7 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
330
331
  content: [
331
332
  {
332
333
  type: 'text',
333
- text: error instanceof Error ? error.message : String(error),
334
+ text: redactSecretsInText(error instanceof Error ? error.message : String(error)),
334
335
  },
335
336
  ],
336
337
  }
@@ -345,11 +346,12 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
345
346
 
346
347
  try {
347
348
  const result = await invokeTool(tool, request.params.arguments)
349
+ const sanitized = redactSecretsForMcpOutput(result)
348
350
  return {
349
351
  content: [
350
352
  {
351
353
  type: 'text',
352
- text: JSON.stringify(result, null, 2),
354
+ text: JSON.stringify(sanitized, null, 2),
353
355
  },
354
356
  ],
355
357
  }
@@ -359,7 +361,7 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
359
361
  content: [
360
362
  {
361
363
  type: 'text',
362
- text: error instanceof Error ? error.message : String(error),
364
+ text: redactSecretsInText(error instanceof Error ? error.message : String(error)),
363
365
  },
364
366
  ],
365
367
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foundation0/git",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "Foundation 0 Git API and MCP server",
5
5
  "type": "module",
6
6
  "bin": {
@@ -218,6 +218,22 @@ const buildUrl = (
218
218
  return url.toString()
219
219
  }
220
220
 
221
+ const unresolvedPathParamPattern = /^\{[^{}]+\}$/
222
+
223
+ const assertResolvedMappedPath = (
224
+ mappedPath: string[],
225
+ featurePath: string[],
226
+ ): void => {
227
+ const unresolved = mappedPath.filter((segment) => unresolvedPathParamPattern.test(segment))
228
+ if (unresolved.length === 0) {
229
+ return
230
+ }
231
+
232
+ throw new Error(
233
+ `Missing required path arguments for "${featurePath.join('.')}". Unresolved parameters: ${unresolved.join(', ')}`,
234
+ )
235
+ }
236
+
221
237
  const canUseAbortSignalTimeout = (): boolean =>
222
238
  typeof AbortSignal !== 'undefined' && typeof (AbortSignal as unknown as { timeout?: unknown }).timeout === 'function'
223
239
 
@@ -369,6 +385,7 @@ const createMethod = (
369
385
 
370
386
  return segment
371
387
  })
388
+ assertResolvedMappedPath(hydratedPath, feature.path)
372
389
 
373
390
  const requestBody = buildRequestBody(mapping.method, bodyOptions, unhandled)
374
391
  const headers = {
@@ -530,8 +547,8 @@ export const createGitServiceApi = (options: GitServiceApiFactoryOptions = {}):
530
547
  const log = options.log
531
548
 
532
549
  const defaults = {
533
- defaultOwner: options.defaultOwner ?? process.env.GITEA_TEST_OWNER ?? 'example-org',
534
- defaultRepo: options.defaultRepo ?? process.env.GITEA_TEST_REPO ?? 'example-repo',
550
+ defaultOwner: options.defaultOwner,
551
+ defaultRepo: options.defaultRepo,
535
552
  }
536
553
 
537
554
  const root: GitServiceApi = {}
@@ -562,4 +579,24 @@ export const createGitServiceApi = (options: GitServiceApiFactoryOptions = {}):
562
579
  return root
563
580
  }
564
581
 
565
- export const gitServiceApi = createGitServiceApi()
582
+ const createUnavailableGitServiceApi = (error: Error): GitServiceApi => {
583
+ return new Proxy(
584
+ {},
585
+ {
586
+ get: (): never => {
587
+ throw error
588
+ },
589
+ },
590
+ ) as GitServiceApi
591
+ }
592
+
593
+ export const gitServiceApi: GitServiceApi = (() => {
594
+ try {
595
+ return createGitServiceApi()
596
+ } catch (error) {
597
+ const message = error instanceof Error ? error.message : String(error)
598
+ return createUnavailableGitServiceApi(
599
+ new Error(`Failed to initialize gitServiceApi singleton: ${message}`),
600
+ )
601
+ }
602
+ })()
@@ -2,7 +2,6 @@ import type { GitServiceApiExecutionResult } from './git-service-api'
2
2
  import { spawn } from 'node:child_process'
3
3
  import crypto from 'node:crypto'
4
4
 
5
- const DEFAULT_GITEA_HOST = 'https://gitea.example.com'
6
5
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000
7
6
 
8
7
  const parseRequestTimeoutMs = (value: unknown): number | null => {
@@ -274,6 +273,14 @@ const resolveGiteaApiBase = (host: string): string => {
274
273
  return trimmed.endsWith('/api/v1') ? trimmed : `${trimmed}/api/v1`
275
274
  }
276
275
 
276
+ const resolveRequiredGiteaHost = (host: string | undefined): string => {
277
+ const resolved = host?.trim() ?? process.env.GITEA_HOST?.trim()
278
+ if (!resolved) {
279
+ throw new Error('GITEA_HOST is required. Pass host explicitly or set process.env.GITEA_HOST.')
280
+ }
281
+ return resolved
282
+ }
283
+
277
284
  const buildIssueDependenciesUrl = (host: string, owner: string, repo: string, issueNumber: number): string => {
278
285
  return `${resolveGiteaApiBase(host)}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}/dependencies`
279
286
  }
@@ -283,11 +290,12 @@ export async function callIssueDependenciesApi(
283
290
  owner: string,
284
291
  repo: string,
285
292
  issueNumber: number,
286
- host: string = DEFAULT_GITEA_HOST,
293
+ host: string | undefined,
287
294
  token: string | undefined,
288
295
  payload?: GitIssueDependencyPayload
289
296
  ): Promise<GitServiceApiExecutionResult<unknown>> {
290
- const requestUrl = buildIssueDependenciesUrl(host, owner, repo, issueNumber)
297
+ const resolvedHost = resolveRequiredGiteaHost(host)
298
+ const requestUrl = buildIssueDependenciesUrl(resolvedHost, owner, repo, issueNumber)
291
299
  const headers = {
292
300
  Accept: 'application/json',
293
301
  ...(token ? { Authorization: `token ${token}` } : {}),
@@ -343,7 +351,7 @@ export async function callIssueDependenciesApi(
343
351
  method,
344
352
  query: [],
345
353
  headers: [],
346
- apiBase: resolveGiteaApiBase(host),
354
+ apiBase: resolveGiteaApiBase(resolvedHost),
347
355
  swaggerPath: '/repos/{owner}/{repo}/issues/{index}/dependencies',
348
356
  mapped: true,
349
357
  },
@@ -8,7 +8,6 @@ export interface GitPlatformConfig {
8
8
  giteaSwaggerPath?: string
9
9
  }
10
10
 
11
- const DEFAULT_GITEA_HOST = 'https://gitea.example.com'
12
11
  const DEFAULT_PLATFORM: PlatformName = 'GITEA'
13
12
  const DEFAULT_GITEA_SWAGGER_PATH = '/swagger.v1.json'
14
13
 
@@ -34,8 +33,7 @@ export const getGitPlatformConfig = (overrides: Partial<GitPlatformConfig> = {})
34
33
 
35
34
  const giteaHost =
36
35
  overrides.giteaHost ??
37
- process.env.GITEA_HOST ??
38
- DEFAULT_GITEA_HOST
36
+ process.env.GITEA_HOST
39
37
 
40
38
  const giteaToken =
41
39
  overrides.giteaToken ??
@@ -50,9 +48,13 @@ export const getGitPlatformConfig = (overrides: Partial<GitPlatformConfig> = {})
50
48
  process.env.GITEA_SWAGGER_PATH ??
51
49
  DEFAULT_GITEA_SWAGGER_PATH
52
50
 
51
+ if (!giteaHost || giteaHost.trim().length === 0) {
52
+ throw new Error('GITEA_HOST is required. Set process.env.GITEA_HOST or pass config.giteaHost explicitly.')
53
+ }
54
+
53
55
  return {
54
56
  platform,
55
- giteaHost,
57
+ giteaHost: giteaHost.trim(),
56
58
  giteaToken,
57
59
  giteaApiVersion,
58
60
  giteaSwaggerPath,