Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,26 @@ MCP 서버는 클라이언트가 시작할 때 프로세스로 떠서 실행되
| `get_service(id)` | 특정 서비스의 상세 정보(필요한 보안패키지 전체, 호환성 주의사항, 아이콘 URL)를 반환합니다 |
| `list_categories()` | 카테고리별 개수를 반환합니다 |
| `list_companions(query?)` | 보조 프로그램(공용 소프트웨어) 목록을 반환합니다 |
| `generate_wsb(serviceIds[])` | 실행용 `.wsb` XML 텍스트를 생성합니다(모든 OS). 지원 러너에서 실행합니다 |
| `launch_sandbox(serviceIds[])` | 즉시 샌드박스를 실행합니다. Windows는 Windows Sandbox, macOS(Apple Silicon)는 [macSandbox](https://github.com/yourtablecloth/macSandbox), 그 외(Linux 등)는 `TABLECLOTH_WSB_RUNNER` 환경변수로 지정한 러너를 씁니다 |
| `check_url(url)` | 어떤 페이지 URL이 보안프로그램이 필요한 사이트인지 판정합니다(실행 없음). 샌드박스로 열지 그냥 브라우저로 열지 고르는 데 씁니다 |
| `generate_wsb(serviceIds?[], targetUrl?)` | 실행용 `.wsb` XML 텍스트를 생성합니다(모든 OS). 지원 러너에서 실행합니다 |
| `launch_sandbox(serviceIds?[], targetUrl?)` | 즉시 샌드박스를 실행합니다. Windows는 Windows Sandbox, macOS(Apple Silicon)는 [macSandbox](https://github.com/yourtablecloth/macSandbox), 그 외(Linux 등)는 `TABLECLOTH_WSB_RUNNER` 환경변수로 지정한 러너를 씁니다 |

`generate_wsb`와 `launch_sandbox`는 `serviceIds`와 `targetUrl` 중 최소 하나가 필요합니다.
`targetUrl`을 주면 보안프로그램 설치가 끝난 뒤 **그 페이지가 그대로** 열립니다(생략하면 서비스의 대표 URL).
`serviceIds` 없이 URL만 줘도 카탈로그에서 해당 서비스를 자동으로 찾아냅니다.

## 예시 흐름 (연말정산)

1. `search_services("연말정산")` 또는 `search_services("홈택스")`로 홈택스 등 후보와 `id`를 얻습니다.
2. `launch_sandbox(["<홈택스 id>"])`를 호출하면 보안프로그램이 갖춰진 일회용 샌드박스가 뜨고 사이트가 열립니다.
3. 인증서나 간편인증, 연말정산간소화 조회와 발급은 사용자가 직접 진행합니다.

## 예시 흐름 (검색으로 찾은 상품 페이지)

1. 웹 검색 MCP로 국민은행의 특정 상품 페이지 URL을 찾습니다.
2. `check_url("<그 URL>")`로 보안프로그램이 필요한 사이트인지 판정합니다.
3. 필요하면 `launch_sandbox(targetUrl: "<그 URL>")` — 보안프로그램이 설치된 샌드박스에서 **그 상품 페이지가 그대로** 열리고, 사용자는 거기서 계속 브라우징합니다. 필요 없으면 사용자가 평소 브라우저로 열면 됩니다.

## TableCloth 리포지터리에 의존하지 않습니다

런타임에 공개된 자산만 소비합니다.
Expand Down
64 changes: 58 additions & 6 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
## 3. 공유 리소스 (`shared/`)

- `shared/strings.json` — 서버 지침, 도구 title/description/파라미터 설명, note/hint, `securityNote`.
- `shared/wsb-template.xml` — `.wsb` 정본 템플릿(치환점 `__SITEIDS__` 1개).
- `shared/wsb-template.xml` — `.wsb` 정본 템플릿(치환점 `__SPORK_SITE_IDS__`, `__SPORK_TARGET_URL__`).
`LogonCommand` 는 상류 릴리스 자산 `no-install-spork-deeplink.wsb` 와 **바이트 일치**를 유지한다.

소비 규칙은 [`shared/README.md`](shared/README.md) 참조. 요약: 문자열은 `shared/` 에서만 수정하고,
C# 의 attribute 상수(도구 Description/Title/파라미터 설명)는 런타임 로드가 불가하므로 코드에 두되
Expand All @@ -49,8 +50,11 @@ C# 의 attribute 상수(도구 Description/Title/파라미터 설명)는 런타
| `get_service` | RO | `id: string`(필수) | `{id, displayName, displayNameEn?, category, url, iconUrl, packages[]: {name,url,arguments}, edgeExtensions[]: {name,extensionId,crxUrl}, searchKeywords?, compatNotes?}` 또는 `{error, hint}` |
| `list_categories` | RO | 없음 | `{totalServices, categories[]: {category, count}}` (count 내림차순) |
| `list_companions` | RO | `query?: string` | `{matched, companions[]: {id, displayName, displayNameEn?, url}}` |
| `generate_wsb` | RO | `serviceIds: string[]`(1개+) | `{siteIds[], unknownIdsIgnored?[], wsb, usage, securityNote}` 또는 `{error, unknownIds[], hint}` |
| `launch_sandbox` | RW, Dx=false | `serviceIds: string[]`(1개+) | `{launched:true, runner, siteIds[], unknownIdsIgnored?[], wsbPath, note, securityNote}` 또는 `{launched:false, error, hint, unknownIds?[]}` |
| `check_url` | RO | `url: string`(필수) | `{url, host, sandboxSupported, serviceId?, displayName?, category?, serviceUrl?, requiredPackages?[], candidates?[], reason?, note?, error?, hint?}` |
| `generate_wsb` | RO | `serviceIds?: string[]`, `targetUrl?: string` (**둘 중 최소 하나**) | `{siteIds[], unknownIdsIgnored?[], targetUrl?, resolvedFromUrl?, targetUrlIgnored?, wsb, usage, targetUrlNote?, securityNote}` 또는 `{error, unknownIds?[], candidates?[], hint}` |
| `launch_sandbox` | RW, Dx=false | `serviceIds?: string[]`, `targetUrl?: string` (**둘 중 최소 하나**) | `{launched:true, runner, siteIds[], unknownIdsIgnored?[], targetUrl?, resolvedFromUrl?, targetUrlIgnored?, wsbPath, note, targetUrlNote?, securityNote}` 또는 `{launched:false, error, hint, unknownIds?[], candidates?[]}` |

오류 응답의 `unknownIds` 는 **비어 있으면 생략**한다(양 구현 동일).

검색 매칭 규칙: `query` 를 공백/쉼표/탭/개행으로 토큰화(소문자, 중복 제거) → 각 서비스의
검색 대상 텍스트(표시명 한/영 + URL + 보안패키지명 + 검색키워드)에 포함되는 토큰 수를 점수로
Expand All @@ -68,13 +72,58 @@ id 문자셋 방어: `.wsb` 주입 전 `^[A-Za-z0-9._-]+$` 만 허용.

## 7. `.wsb` 생성 계약

- 템플릿: `shared/wsb-template.xml` 로드 후 `__SITEIDS__` 를 사이트 사전선택 구문으로 치환.
- 사이트 주입 채널: 환경변수 `TABLECLOTH_SITE_IDS`(PARAMETERIZED_WSB_SPEC.md §0.5). 치환 구문 예:
` $env:TABLECLOTH_SITE_IDS = ''<id1> <id2>'';` (id 없으면 빈 문자열).
- 템플릿: `shared/wsb-template.xml` 로드 후 치환점 2개를 **값 자리에만** 채운다.
주입 채널은 환경변수 2개이며 서로 독립이다(PARAMETERIZED_WSB_SPEC.md §3.3).

| 치환점 | 환경변수 | 값 |
| --- | --- | --- |
| `__SPORK_SITE_IDS__` | `TABLECLOTH_SITE_IDS` | 공백으로 이은 카탈로그 id. 없으면 빈 문자열 |
| `__SPORK_TARGET_URL__` | `TABLECLOTH_TARGET_URL` | 설치 후 열 페이지 URL. 없으면 빈 문자열 |

- **`tablecloth:` URI 스킴 딥링크는 생성하지 않는다.** 그 핸들러는 Windows TableCloth 앱만 등록해
macOS/Linux 에서 GA 가 아니다. 이 서버의 산출물은 항상 `.wsb` 파일이며, 이 파일을 소비하는 게스트는
(Windows Sandbox 든 macSandbox 든) 항상 Windows 라 위 두 채널은 모든 호스트에서 동일하게 동작한다.
- id 는 `^[A-Za-z0-9._-]+$` 화이트리스트를 통과했으므로 이스케이프하지 않는다.
- 실행 자산은 전부 GitHub 릴리스 공개 URL(`tablecloth-prepare.ps1` 등). 무설치 Express 레인.
- `securityNote`: 응답에 항상 포함. 문구는 `strings.json` 의 `sandbox.securityNote`.
- 알려진 이슈/하드닝: 명령이 원격 스크립트 실행 형태라 오탐될 수 있음 → [#1](https://github.com/yourtablecloth/TableClothMcp/issues/1).

### 7.1 `targetUrl` 채널 계약

검색 MCP 등이 찾아 준 **딥 URL 을 그대로** 샌드박스에서 열기 위한 채널. 두 구현이 같은 입력에
**같은 바이트**를 내야 한다(conformance 가 강제).

**검증**(생산자 의무, PARAMETERIZED_WSB_SPEC §3.3) — 실패 시 `reason` 을 오류 문구에 넣어 거부:

- `http://` 또는 `https://` 로 시작하는 절대 URL (`notHttp`)
- 자격증명(`user@host`) 없음 (`credentials`)
- 2048자 이하 (`tooLong`), 공백·제어문자 없음 (`badChars`), 호스트 존재 (`noHost`)

**이스케이프**(두 층, 순서 고정) — 두 층이 건드리는 문자 집합은 겹치지 않아 순서는 결과에 무관하지만
구현 간 동일성을 위해 고정한다:

1. PowerShell/argv 층: `'` → `%27`, `"` → `%22`, **그리고 비ASCII(> U+007E) 전부 UTF-8 퍼센트 인코딩.**
정본 `.wsb` 가 ASCII only 를 요구한다(게스트 코드페이지가 호스트 언어팩마다 달라 한글이 깨지고,
macSandbox 는 명령을 `.cmd` 파일로 한 번 더 경유시킨다).
2. XML 층: `&` `<` `>` → 엔티티. 실제 은행 URL 의 쿼리스트링에 `&` 가 흔해 필수.

URL 은 Base64 등으로 감추지 않고 **평문**으로 둔다 — 실행 전에 어떤 주소가 열리는지 눈으로 확인할 수
있어야 한다는 정본의 신뢰 모델을 그대로 따른다.

**해석**(호스트측, 카탈로그 기준. PARAMETERIZED_WSB_SPEC §6.5 의 호스트측 대응):

- 등록 도메인은 **퍼블릭 서픽스를 인식해** 계산한다. 한국 2단계 서픽스(`co.kr`, `or.kr`, `go.kr` 등)를
처리하지 않으면 `co.kr` 전체가 한 덩어리가 되므로 필수다.
- `serviceIds` 가 함께 오면: **모든 id 가 URL 과 같은 등록 도메인**이어야 URL 이 살아남는다(§6.5-3).
어긋나면 게스트도 URL 만 버리므로, 호스트가 먼저 버리고 `targetUrlIgnored` 로 사실대로 알린다
(id 채널은 그대로 동작 — 오류가 아니다).
- `targetUrl` 만 오면: 같은 등록 도메인 후보 중 **호스트 라벨이 가장 많이 일치**하는 하나로 해석한다.
유일하면 그 id 를 `siteIds` 에 넣고 `resolvedFromUrl` 로 알린다.
**동점이면 추측하지 않고** `candidates` 를 돌려준다 — 게스트는 조용히 카탈로그 선순위를 택할 수밖에
없지만, 호스트에는 되물을 수 있는 모델이 있고 잘못 고르면 엉뚱한 보안프로그램이 설치된다.
- 카탈로그 도메인 밖이면 거부한다. 게스트가 어차피 URL 을 버리고 카탈로그 UI 를 띄우므로, 조용한
거짓 성공을 만들지 않으려면 호스트가 먼저 막아야 한다.

## 8. 샌드박스 실행 계약 (`launch_sandbox`)

러너 결정 순서:
Expand Down Expand Up @@ -108,6 +157,9 @@ id 문자셋 방어: `.wsb` 주입 전 `^[A-Za-z0-9._-]+$` 만 허용.
2. 대표 입력의 `tools/call` 출력 JSON 동일:
- `search_services("은행")`, `list_categories()`, `get_service("Hometax")`, `generate_wsb(["Hometax"])`
- `generate_wsb` 의 `wsb` 는 `shared/wsb-template.xml` + 주입 구문과 바이트 일치(개행 정규화 허용).
- `targetUrl` 채널: 딥 URL(쿼리스트링 `&`), URL-only 자동 판별, 동점, 카탈로그 밖 도메인,
id/URL 도메인 불일치, 형식 위반, 비ASCII 경로 — 7개 케이스의 응답이 두 구현에서 동일하고
생성된 `.wsb` 가 ASCII 를 유지하는지 검증한다.
3. `securityNote` 등 note/hint 가 `strings.json` 과 일치.

카탈로그는 라이브 데이터라, 동일성 비교는 **같은 시점 스냅샷**을 두 구현에 주입하거나 필드 구조/불변 항목
Expand Down
33 changes: 32 additions & 1 deletion SharedResources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ internal static class SharedResources
public static readonly string WsbTemplate;
// sandbox 공용
public static readonly string SecurityNote;
public static readonly string TargetUrlNote;
public static readonly string ErrorNoTarget;
public static readonly string HintNoTarget;
public static readonly string TargetUrlInvalidError; // {reason}
public static readonly string TargetUrlInvalidHint;
public static readonly string TargetUrlNoMatchError; // {host}
public static readonly string TargetUrlNoMatchHint;
public static readonly string TargetUrlAmbiguousError; // {host}
public static readonly string TargetUrlAmbiguousHint; // {candidates}
public static readonly string TargetUrlIdMismatchError; // {host}
public static readonly string TargetUrlIdMismatchHint;
// check_url
public static readonly string CheckUrlNoteSupported;
public static readonly string CheckUrlNoteUnsupported;
// search_services
public static readonly string SearchResultNote;
// get_service
Expand Down Expand Up @@ -47,7 +61,24 @@ static SharedResources()
var tools = root.GetProperty("tools");

ServerInstructions = Str(root.GetProperty("server"), "instructions");
SecurityNote = Str(root.GetProperty("sandbox"), "securityNote");

var sb = root.GetProperty("sandbox");
SecurityNote = Str(sb, "securityNote");
TargetUrlNote = Str(sb, "targetUrlNote");
ErrorNoTarget = Str(sb, "errorNoTarget");
HintNoTarget = Str(sb, "hintNoTarget");
TargetUrlInvalidError = Str(sb, "targetUrlInvalidError");
TargetUrlInvalidHint = Str(sb, "targetUrlInvalidHint");
TargetUrlNoMatchError = Str(sb, "targetUrlNoMatchError");
TargetUrlNoMatchHint = Str(sb, "targetUrlNoMatchHint");
TargetUrlAmbiguousError = Str(sb, "targetUrlAmbiguousError");
TargetUrlAmbiguousHint = Str(sb, "targetUrlAmbiguousHint");
TargetUrlIdMismatchError = Str(sb, "targetUrlIdMismatchError");
TargetUrlIdMismatchHint = Str(sb, "targetUrlIdMismatchHint");

var cu = tools.GetProperty("check_url");
CheckUrlNoteSupported = Str(cu, "noteSupported");
CheckUrlNoteUnsupported = Str(cu, "noteUnsupported");

var ss = tools.GetProperty("search_services");
SearchResultNote = Str(ss, "resultNote");
Expand Down
1 change: 1 addition & 0 deletions Tools/AppJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ namespace TableCloth.Mcp.Tools;
[JsonSerializable(typeof(CompanionsResponse))]
[JsonSerializable(typeof(WsbResponse))]
[JsonSerializable(typeof(LaunchResponse))]
[JsonSerializable(typeof(CheckUrlResponse))]
internal sealed partial class AppJsonContext : JsonSerializerContext;
73 changes: 73 additions & 0 deletions Tools/CatalogTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,79 @@ public sealed class CatalogTools
{
private static readonly char[] Separators = { ' ', ',', '\t', '\n', '\r' };

[McpServerTool(Name = "check_url", Title = "URL 샌드박스 필요 여부 판정", ReadOnly = true, OpenWorld = true)]
[Description(
"웹 검색 등으로 얻은 페이지 URL 이 보안프로그램(공동인증서, 키보드보안 등)이 필요한 한국 공공/금융 " +
"사이트인지 판정한다. 아무것도 실행하지 않고 판정 정보만 돌려준다.\n\n" +
"이럴 때 사용하라: 대화 중 특정 은행/공공기관의 상품·안내 페이지 URL 을 확보했고, 사용자가 그 페이지로 " +
"접속하려 할 때. 이 도구로 먼저 판정한 뒤, 샌드박스가 필요하면 launch_sandbox 에 그 URL 을 targetUrl 로 " +
"넘겨 열고, 필요 없으면 사용자가 평소 브라우저로 열도록 안내한다.\n\n" +
"이 서버는 호스트(사용자 PC)의 브라우저를 직접 열지 않는다. 판정과 샌드박스 실행만 담당한다.")]
public static async Task<CheckUrlResponse> CheckUrl(
CatalogClient catalog,
[Description("판정할 페이지의 전체 URL(http/https 절대 URL).")] string url,
CancellationToken ct = default)
{
if (!TargetUrl.TryValidate(url, out var normalized, out var host, out var reason))
{
return new CheckUrlResponse
{
Url = (url ?? string.Empty).Trim(),
SandboxSupported = false,
Reason = "invalid",
Error = SharedResources.TargetUrlInvalidError.Replace("{reason}", TargetUrl.ReasonText(reason)),
Hint = SharedResources.TargetUrlInvalidHint,
};
}

var doc = await catalog.GetAsync(ct: ct).ConfigureAwait(false);
var r = TargetUrl.Resolve(doc.Services, host);

if (r.Kind == TargetUrl.ResolutionKind.NoMatch)
{
return new CheckUrlResponse
{
Url = normalized,
Host = host,
SandboxSupported = false,
Reason = "noMatch",
Note = SharedResources.CheckUrlNoteUnsupported,
Hint = SharedResources.TargetUrlNoMatchHint,
};
}

if (r.Kind == TargetUrl.ResolutionKind.Ambiguous)
{
var candidates = r.Candidates
.Select(c => new UrlCandidateDto { Id = c.Id, DisplayName = c.DisplayName, Url = c.Url })
.ToList();
return new CheckUrlResponse
{
Url = normalized,
Host = host,
SandboxSupported = false,
Reason = "ambiguous",
Candidates = candidates,
Error = SharedResources.TargetUrlAmbiguousError.Replace("{host}", host),
Hint = SharedResources.TargetUrlAmbiguousHint.Replace("{candidates}", string.Join(", ", candidates.Select(c => c.Id))),
};
}

var svc = doc.Services.First(s => string.Equals(s.Id, r.Id, StringComparison.Ordinal));
return new CheckUrlResponse
{
Url = normalized,
Host = host,
SandboxSupported = true,
ServiceId = svc.Id,
DisplayName = svc.DisplayName,
Category = svc.Category,
ServiceUrl = svc.Url,
RequiredPackages = svc.Packages.Select(p => p.Name).ToArray(),
Note = SharedResources.CheckUrlNoteSupported,
};
}

[McpServerTool(Name = "search_services", Title = "한국 공공/금융 사이트 검색", ReadOnly = true, OpenWorld = true)]
[Description(
"한국의 은행, 금융, 공공(e-Gov), 정부 사이트를 사용자가 실제로 이용하려 할 때 알맞은 공식 사이트(카탈로그 " +
Expand Down
Loading
Loading